Make Gitea publishing claimant controlled
This commit is contained in:
+190
-32
@@ -35,6 +35,32 @@ opengist_svc = OpenGistAdapter()
|
||||
# Rate limiting sliding window in-memory cache: ip -> list of timestamps
|
||||
SUBMISSION_IP_LOG: Dict[str, List[float]] = {}
|
||||
|
||||
|
||||
def _resolve_gitea_claimant(conn, idea, current_user: User) -> str:
|
||||
"""Authorizes claimant-controlled publication and returns the Gitea username."""
|
||||
claimed_by = idea["claimed_by"]
|
||||
if not claimed_by:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The idea must be claimed before it can be published to Gitea."
|
||||
)
|
||||
if claimed_by != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the current claimant can publish this idea to Gitea."
|
||||
)
|
||||
|
||||
claimant = conn.execute(
|
||||
"SELECT username, gitea_id FROM users WHERE username = ?",
|
||||
(claimed_by,)
|
||||
).fetchone()
|
||||
if not claimant or claimant["gitea_id"] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="The claimant must sign in with Gitea before publishing this idea."
|
||||
)
|
||||
return claimant["username"]
|
||||
|
||||
class IdeaSubmissionRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
@@ -378,6 +404,86 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
|
||||
except Exception:
|
||||
sub_img = None
|
||||
|
||||
# Build multi-version research stages
|
||||
stage_definitions = {
|
||||
"IMAGE_CONTEXT": {"name": "Visual & Image Context", "doc_name": "image-context.md", "icon": "🖼️"},
|
||||
"PRIOR_ART": {"name": "Prior Art & Alternatives", "doc_name": "prior-art.md", "icon": "🔍"},
|
||||
"RESEARCH": {"name": "Deep Research Synthesis", "doc_name": "analysis.md", "icon": "📚"},
|
||||
"FEASIBILITY": {"name": "Feasibility & Critique", "doc_name": "feasibility.md", "icon": "⚙️"}
|
||||
}
|
||||
|
||||
research_stages = {}
|
||||
for stage_key, defn in stage_definitions.items():
|
||||
stage_runs = [
|
||||
rn for rn in provenance
|
||||
if rn["stage"] == stage_key and rn["output_data"] and rn["output_data"].get("content")
|
||||
]
|
||||
stage_runs_sorted = sorted(stage_runs, key=lambda x: x["started_at"] or "")
|
||||
v_list = []
|
||||
for idx, r_item in enumerate(stage_runs_sorted, start=1):
|
||||
v_obj = {
|
||||
"version": idx,
|
||||
"is_latest": False,
|
||||
"run_id": r_item["id"],
|
||||
"processor_name": r_item["processor_name"],
|
||||
"model": r_item["resolved_model"] or "Default Policy",
|
||||
"provider": r_item["resolved_provider"] or "",
|
||||
"tokens": {
|
||||
"input": r_item["input_tokens"],
|
||||
"output": r_item["output_tokens"],
|
||||
"total": r_item["total_tokens"]
|
||||
},
|
||||
"started_at": r_item["started_at"],
|
||||
"completed_at": r_item["completed_at"],
|
||||
"duration_ms": r_item["duration_ms"],
|
||||
"prompt_id": r_item["prompt_id"],
|
||||
"prompt_version": r_item["prompt_version"],
|
||||
"artifact_path": r_item["output_artifact"] or f"research/{defn['doc_name']}",
|
||||
"content": r_item["output_data"].get("content", "")
|
||||
}
|
||||
v_list.append(v_obj)
|
||||
|
||||
if v_list:
|
||||
v_list[-1]["is_latest"] = True
|
||||
latest_version = v_list[-1]
|
||||
else:
|
||||
latest_version = None
|
||||
|
||||
research_stages[stage_key] = {
|
||||
"stage": stage_key,
|
||||
"name": defn["name"],
|
||||
"doc_name": defn["doc_name"],
|
||||
"icon": defn["icon"],
|
||||
"versions": v_list,
|
||||
"latest": latest_version,
|
||||
"total_versions": len(v_list)
|
||||
}
|
||||
|
||||
claimant_gitea_linked = False
|
||||
if idea_row["claimed_by"]:
|
||||
claimant_row = conn.execute(
|
||||
"SELECT gitea_id FROM users WHERE username = ?",
|
||||
(idea_row["claimed_by"],)
|
||||
).fetchone()
|
||||
claimant_gitea_linked = bool(
|
||||
claimant_row and claimant_row["gitea_id"] is not None
|
||||
)
|
||||
|
||||
actor_is_claimant = idea_row["claimed_by"] == current_user.username
|
||||
gitea_publish_allowed = bool(
|
||||
idea_row["claimed_by"]
|
||||
and claimant_gitea_linked
|
||||
and (actor_is_claimant or is_admin)
|
||||
)
|
||||
if not idea_row["claimed_by"]:
|
||||
gitea_publish_reason = "Claim this idea before publishing it to Gitea."
|
||||
elif not actor_is_claimant and not is_admin:
|
||||
gitea_publish_reason = "Only the current claimant can publish this idea."
|
||||
elif not claimant_gitea_linked:
|
||||
gitea_publish_reason = "The claimant must sign in with Gitea before publishing."
|
||||
else:
|
||||
gitea_publish_reason = ""
|
||||
|
||||
return {
|
||||
"id": idea_row["id"],
|
||||
"title": idea_row["title"],
|
||||
@@ -395,12 +501,17 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
|
||||
"profile_id": idea_row["profile_id"],
|
||||
"opengist_id": idea_row["opengist_id"],
|
||||
"opengist_url": idea_row["opengist_url"],
|
||||
"gitea_repo_name": idea_row["gitea_repo_name"] if "gitea_repo_name" in idea_row.keys() else "",
|
||||
"gitea_repo_url": idea_row["gitea_repo_url"] if "gitea_repo_url" in idea_row.keys() else "",
|
||||
"gitea_publish_allowed": gitea_publish_allowed,
|
||||
"gitea_publish_reason": gitea_publish_reason,
|
||||
"submission_image": sub_img,
|
||||
"categories": cats,
|
||||
"tags": tags,
|
||||
"urls": urls,
|
||||
"work_tracks": work_tracks,
|
||||
"provenance": provenance,
|
||||
"research_stages": research_stages,
|
||||
"relationships": rels,
|
||||
"usage_summary": {
|
||||
"total_tokens": total_tokens_sum,
|
||||
@@ -535,10 +646,20 @@ async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depen
|
||||
if track["work_type_id"] != "CODING_PROJECT":
|
||||
raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.")
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
|
||||
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.")
|
||||
claimant_username = _resolve_gitea_claimant(conn, idea, current_user)
|
||||
|
||||
res = await gitea_svc.graduate_project(idea["id"], idea["title"], idea["summary"])
|
||||
try:
|
||||
res = await gitea_svc.graduate_project(
|
||||
idea["id"],
|
||||
idea["title"],
|
||||
idea["summary"],
|
||||
collaborator_username=claimant_username
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc)
|
||||
) from exc
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
@@ -721,9 +842,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
|
||||
|
||||
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
|
||||
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()]
|
||||
|
||||
# Build research docs
|
||||
# Build research docs (chronologically latest run content takes precedence)
|
||||
research_docs = {}
|
||||
for r in runs:
|
||||
stage = r.get("stage")
|
||||
@@ -734,7 +855,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
|
||||
out_data = {}
|
||||
content = out_data.get("content")
|
||||
if content:
|
||||
if stage == "PRIOR_ART":
|
||||
if stage == "IMAGE_CONTEXT":
|
||||
research_docs["image-context.md"] = content
|
||||
elif stage == "PRIOR_ART":
|
||||
research_docs["prior-art.md"] = content
|
||||
elif stage == "RESEARCH":
|
||||
research_docs["analysis.md"] = content
|
||||
@@ -772,18 +895,19 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/sync-gitea")
|
||||
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org."""
|
||||
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Publishes or re-syncs a claimed idea and grants its claimant write access."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
claimant_username = _resolve_gitea_claimant(conn, idea, current_user)
|
||||
|
||||
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
|
||||
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()]
|
||||
|
||||
# Build research docs
|
||||
# Build research docs (chronologically latest run content takes precedence)
|
||||
research_docs = {}
|
||||
for r in runs:
|
||||
stage = r.get("stage")
|
||||
@@ -794,7 +918,9 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
out_data = {}
|
||||
content = out_data.get("content")
|
||||
if content:
|
||||
if stage == "PRIOR_ART":
|
||||
if stage == "IMAGE_CONTEXT":
|
||||
research_docs["image-context.md"] = content
|
||||
elif stage == "PRIOR_ART":
|
||||
research_docs["prior-art.md"] = content
|
||||
elif stage == "RESEARCH":
|
||||
research_docs["analysis.md"] = content
|
||||
@@ -813,19 +939,30 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
track_slug = tr["name"].lower().replace(" ", "-")
|
||||
outputs[f"{track_slug}/{out['name']}"] = out["content"]
|
||||
|
||||
res = await gitea_svc.persist_idea_dossier_repo(
|
||||
idea_id=idea["id"],
|
||||
title=idea["title"] or "Untitled Idea",
|
||||
summary=idea["summary"] or "",
|
||||
original_text=idea["original_text"] or "",
|
||||
categories=cats,
|
||||
tags=tags,
|
||||
lifecycle_state=idea["lifecycle_state"],
|
||||
research_docs=research_docs,
|
||||
outputs=outputs,
|
||||
provenance_runs=runs,
|
||||
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
|
||||
)
|
||||
try:
|
||||
res = await gitea_svc.persist_idea_dossier_repo(
|
||||
idea_id=idea["id"],
|
||||
title=idea["title"] or "Untitled Idea",
|
||||
summary=idea["summary"] or "",
|
||||
original_text=idea["original_text"] or "",
|
||||
categories=cats,
|
||||
tags=tags,
|
||||
lifecycle_state=idea["lifecycle_state"],
|
||||
research_docs=research_docs,
|
||||
outputs=outputs,
|
||||
provenance_runs=runs,
|
||||
existing_repo_url=(
|
||||
idea["gitea_repo_url"]
|
||||
if "gitea_repo_url" in idea.keys() else None
|
||||
),
|
||||
publish_remote=True,
|
||||
collaborator_username=claimant_username
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc)
|
||||
) from exc
|
||||
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
@@ -837,16 +974,38 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
""",
|
||||
(res["repo_name"], res["repo_url"], now, idea_id)
|
||||
)
|
||||
conn.execute(
|
||||
existing_resource = conn.execute(
|
||||
"""
|
||||
INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at)
|
||||
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
|
||||
SELECT id FROM external_resources
|
||||
WHERE idea_id = ? AND resource_type = 'GITEA_DOSSIER'
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""",
|
||||
(idea_id, res["repo_url"], json.dumps(res), now)
|
||||
)
|
||||
(idea_id,)
|
||||
).fetchone()
|
||||
if existing_resource:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE external_resources
|
||||
SET url = ?, metadata = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(res["repo_url"], json.dumps(res), existing_resource["id"])
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO external_resources
|
||||
(idea_id, resource_type, url, metadata, created_at)
|
||||
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
|
||||
""",
|
||||
(idea_id, res["repo_url"], json.dumps(res), now)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Dossier successfully published to Gitea under '{res['repo_name']}'.",
|
||||
"message": (
|
||||
f"Dossier published to '{res['repo_name']}' and write access granted "
|
||||
f"to '{claimant_username}'."
|
||||
),
|
||||
"gitea_repo_name": res["repo_name"],
|
||||
"gitea_repo_url": res["repo_url"],
|
||||
"clone_url": res["clone_url"],
|
||||
@@ -859,7 +1018,7 @@ async def reprocess_idea(
|
||||
bypass_duplicate_check: bool = True,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Triggers end-to-end reprocessing of an idea (Prior Art, Deep Research, Feasibility, and Gitea Dossier)."""
|
||||
"""Triggers research reprocessing without automatically publishing to Gitea."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
@@ -903,4 +1062,3 @@ async def get_idea_image(idea_id: str):
|
||||
media_type=mime,
|
||||
filename=meta.get("original_filename", f"{idea_id}_reference_image")
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user