Make Gitea publishing claimant controlled

This commit is contained in:
2026-08-27 16:13:29 -07:00
parent 41e08611c9
commit a76ad5258b
10 changed files with 797 additions and 215 deletions
+190 -32
View File
@@ -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")
)