Slice 1: scaffolding + propose-to-super-draft vertical
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""The bot wrapper.
|
||||
|
||||
Per §1: the bot service account is the only Git writer in the system.
|
||||
Per §6.5: every commit, branch creation, and PR merge carries an
|
||||
On-behalf-of: trailer naming the acting user.
|
||||
|
||||
This module is the single chokepoint. Every write to Gitea — file
|
||||
creation, branch creation, PR open, PR merge, PR close — flows through
|
||||
a Bot method that takes an `actor` (the authenticated user whose gesture
|
||||
produced the action) and an `action_kind` (one of the values recorded
|
||||
in the `actions` table). The wrapper:
|
||||
|
||||
- calls the Gitea HTTP client with the bot's credentials,
|
||||
- appends the trailer to commit/PR/comment bodies,
|
||||
- records a row in `actions` so the app's accountability surface and
|
||||
the Git log carry the same record.
|
||||
|
||||
If you find yourself wanting to import gitea.py directly to perform a
|
||||
write, the spec is right and you are wrong: the wrapper is the
|
||||
invariant. Read operations live in `gitea.py` and can be called from
|
||||
anywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from .gitea import Gitea
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Actor:
|
||||
"""The user whose gesture is producing a write."""
|
||||
user_id: int
|
||||
gitea_login: str
|
||||
display_name: str
|
||||
email: str
|
||||
|
||||
|
||||
def _trailer(actor: Actor) -> str:
|
||||
return f"On-behalf-of: {actor.display_name} <{actor.gitea_login}>"
|
||||
|
||||
|
||||
def _stamp(message_subject: str, message_body: str, actor: Actor) -> tuple[str, str]:
|
||||
"""Compose subject + body with the On-behalf-of trailer appended.
|
||||
|
||||
Subject and body are returned separately because Gitea's merge API
|
||||
takes them on distinct fields; for file commits we hand back a
|
||||
single string in the caller.
|
||||
"""
|
||||
body = message_body.rstrip()
|
||||
trailer = _trailer(actor)
|
||||
if body:
|
||||
return message_subject, f"{body}\n\n{trailer}"
|
||||
return message_subject, trailer
|
||||
|
||||
|
||||
def _stamp_single(message: str, actor: Actor) -> str:
|
||||
subject, _, rest = message.partition("\n")
|
||||
subject, body = _stamp(subject, rest.lstrip(), actor)
|
||||
return f"{subject}\n\n{body}".rstrip()
|
||||
|
||||
|
||||
def _log(
|
||||
actor: Actor,
|
||||
action_kind: str,
|
||||
*,
|
||||
rfc_slug: str | None = None,
|
||||
branch_name: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
bot_commit_sha: str | None = None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO actions
|
||||
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
actor.user_id,
|
||||
actor.gitea_login,
|
||||
action_kind,
|
||||
rfc_slug,
|
||||
branch_name,
|
||||
pr_number,
|
||||
bot_commit_sha,
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self, gitea: Gitea):
|
||||
self._gitea = gitea
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
file_contents: str,
|
||||
pr_title: str,
|
||||
pr_description: str,
|
||||
) -> dict:
|
||||
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
|
||||
|
||||
One file per PR keeps idea submissions atomic and conflict-free.
|
||||
The PR title and the file-add commit subject share §9.2's fixed
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
||||
"""
|
||||
branch = f"propose/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
commit_subject = pr_title # §9.2: shared pattern
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
meta_repo,
|
||||
f"rfcs/{slug}.md",
|
||||
content=file_contents,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
commit_sha = created.get("commit", {}).get("sha")
|
||||
pr_body_subject, pr_body = _stamp("", pr_description, actor)
|
||||
del pr_body_subject # only the body matters here
|
||||
pr = await self._gitea.create_pull(
|
||||
org,
|
||||
meta_repo,
|
||||
title=pr_title,
|
||||
body=pr_body,
|
||||
head=branch,
|
||||
base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"propose_rfc",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_idea_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
slug: str,
|
||||
) -> None:
|
||||
"""Per §9.3: owner/admin merges an idea PR, creating the super-draft."""
|
||||
subject = f"Merge proposal: {slug}"
|
||||
body = _trailer(actor)
|
||||
await self._gitea.merge_pull(
|
||||
org,
|
||||
meta_repo,
|
||||
pr_number,
|
||||
merge_message_title=subject,
|
||||
merge_message_body=body,
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"merge_proposal",
|
||||
rfc_slug=slug,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
async def decline_idea_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
slug: str,
|
||||
comment: str,
|
||||
) -> None:
|
||||
"""Per §9.3: owner/admin declines an idea PR with a required comment.
|
||||
|
||||
The comment is posted to the PR (the durable Git artifact) and a
|
||||
mirroring system-author thread_messages row is written by the
|
||||
caller so the chat record carries the act inline.
|
||||
"""
|
||||
commented = comment.strip() or "(no comment provided)"
|
||||
body = f"{commented}\n\n{_trailer(actor)}"
|
||||
await self._gitea.create_issue_comment(org, meta_repo, pr_number, body)
|
||||
await self._gitea.close_pull(org, meta_repo, pr_number)
|
||||
_log(
|
||||
actor,
|
||||
"decline_proposal",
|
||||
rfc_slug=slug,
|
||||
pr_number=pr_number,
|
||||
details={"comment": commented},
|
||||
)
|
||||
|
||||
async def withdraw_idea_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
slug: str,
|
||||
) -> None:
|
||||
await self._gitea.close_pull(org, meta_repo, pr_number)
|
||||
_log(
|
||||
actor,
|
||||
"withdraw_proposal",
|
||||
rfc_slug=slug,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
Reference in New Issue
Block a user