33212c71e4
Adds the global-Owner create-project action and the role-aware deployment directory, completing slice S5 of the three-tier refactor (release v0.44.0, minor/non-breaking — no migration). Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5), Part C.3 (C3.1–C3.2). Backend: - auth.can_create_project — global-Owner gate (deployment owner/admin or an explicit scope_type='global' Owner grant). - bot.create_project — provision the Gitea content repo (seed README so main exists), read+append+commit projects.yaml in the registry repo, audit-log (create_project). The bot stays the only git writer (§1). - POST /api/projects (api_deployment) — global-Owner gated; validates the id (slug, not 'default'), name, type, visibility, content_repo; commits via the bot, then re-mirrors the registry so projects + default collection rows flow from git (§22.2). GET /api/deployment gains viewer.can_create_project and default_project_readable. make_router now takes gitea + bot. Frontend: - api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable + refresh; DeploymentLanding redirects into the default only when readable (gated/absent default falls through to the directory, no 404 bounce). - Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA; C3.2 "Nothing has been shared with you yet") + Owner-only "New project" control + CreateProjectModal. Tests: backend test_create_project_vertical.py (vertical + gates + the deployment empty-state signals); frontend Directory.test.jsx empty-state cases. Also: ignore the session-local .superpowers/ tooling dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1294 lines
44 KiB
Python
1294 lines
44 KiB
Python
"""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 asyncio
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from . import db, entry as entry_mod, notify
|
|
from .gitea import Gitea, GiteaError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
_MERGE_TRANSIENT_HINTS = (
|
|
"please try again later",
|
|
"is not ready",
|
|
"not ready to be merged",
|
|
)
|
|
|
|
|
|
async def _merge_with_retry(
|
|
gitea: Gitea,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
*,
|
|
merge_message_title: str,
|
|
merge_message_body: str,
|
|
attempts: int = 3,
|
|
delay_seconds: float = 1.0,
|
|
) -> None:
|
|
"""Call gitea.merge_pull and retry on Gitea's transient
|
|
"Please try again later" / "not ready" responses. After
|
|
`wait_for_mergeable` returns this is rare, but Gitea has been
|
|
observed to flip back to the unready state momentarily on a
|
|
write-amplified instance; a small bounded retry closes the gap.
|
|
|
|
Any other GiteaError propagates immediately so the §13.3
|
|
orchestrator can fail the step and run rollback.
|
|
"""
|
|
last_error: GiteaError | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
await gitea.merge_pull(
|
|
org, meta_repo, pr_number,
|
|
merge_message_title=merge_message_title,
|
|
merge_message_body=merge_message_body,
|
|
style="merge",
|
|
)
|
|
return
|
|
except GiteaError as e:
|
|
detail = (e.detail or "").lower()
|
|
if not any(hint in detail for hint in _MERGE_TRANSIENT_HINTS):
|
|
raise
|
|
last_error = e
|
|
log.warning(
|
|
"merge_pull transient (attempt %d/%d) for %s/%s#%d: %s",
|
|
attempt, attempts, org, meta_repo, pr_number, e.detail,
|
|
)
|
|
if attempt < attempts:
|
|
await asyncio.sleep(delay_seconds)
|
|
assert last_error is not None
|
|
raise last_error
|
|
|
|
|
|
@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,
|
|
),
|
|
)
|
|
# §15 chokepoint per §19.1 brief: fan-out runs inline after the
|
|
# audit row lands. notify.py owns the routing rules and the
|
|
# auto-watch upsert per §15.6; the call is intentionally a
|
|
# single line here so the chokepoint is one place to read.
|
|
notify.fan_out_from_action(
|
|
actor_user_id=actor.user_id,
|
|
action_kind=action_kind,
|
|
rfc_slug=rfc_slug,
|
|
branch_name=branch_name,
|
|
pr_number=pr_number,
|
|
details=details,
|
|
)
|
|
|
|
|
|
class Bot:
|
|
def __init__(self, gitea: Gitea):
|
|
self._gitea = gitea
|
|
|
|
# ----- Content repo: collection structure (§22 S2) -----
|
|
|
|
async def create_collection(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
content_repo: str,
|
|
collection_id: str,
|
|
manifest_yaml: str,
|
|
) -> dict:
|
|
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
|
|
repo's main. A structural admin action — committed straight to main (no
|
|
PR), like the registry config it feeds; the registry mirror then upserts
|
|
the collections row (§22.2 keeps the registry the source of truth). Logs
|
|
an audit row for the §6.5 trail."""
|
|
path = f"{collection_id}/.collection.yaml"
|
|
created = await self._gitea.create_file(
|
|
org,
|
|
content_repo,
|
|
path,
|
|
content=manifest_yaml,
|
|
message=_stamp_single(f"chore: create collection {collection_id}", actor),
|
|
branch="main",
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
_log(
|
|
actor,
|
|
"create_collection",
|
|
bot_commit_sha=created.get("commit", {}).get("sha"),
|
|
details={"collection_id": collection_id, "repo": content_repo},
|
|
)
|
|
return created
|
|
|
|
async def create_project(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
registry_repo: str,
|
|
content_repo: str,
|
|
project_id: str,
|
|
projects_yaml_new: str,
|
|
projects_yaml_sha: str,
|
|
readme_text: str,
|
|
) -> dict:
|
|
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
|
|
a bot write at two git sources:
|
|
|
|
1. **provision the content repo** — create `org/content_repo` if it
|
|
doesn't exist, then seed a `README.md` on `main` so the branch
|
|
exists (the contents API initialises the repo with that commit; the
|
|
corpus mirror and the propose path both need a `main` to write to).
|
|
2. **register the project** — commit the caller-composed
|
|
`projects.yaml` (the existing doc with the new project appended) to
|
|
the registry repo's `main`.
|
|
|
|
Like `create_collection`, this is a structural admin action committed
|
|
straight to main (no PR), like the registry config it feeds; the caller
|
|
then re-runs the registry mirror so the new `projects` + default
|
|
`collections` rows flow from the registry (§22.2 keeps the registry the
|
|
source of truth). Logs a `create_project` audit row for the §6.5 trail.
|
|
Returns the registry update_file result (carries the new commit sha)."""
|
|
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
existing = await self._gitea.get_repo(org, content_repo)
|
|
if existing is None:
|
|
await self._gitea.create_org_repo(
|
|
org, content_repo, description=f"Content repo for project {project_id}"
|
|
)
|
|
# Seed a README if absent, which also establishes `main` on a freshly
|
|
# created (auto_init=False) repo — the contents API initialises the repo
|
|
# with that commit. Keyed on the README rather than the branch so it is
|
|
# idempotent and behaves identically whether the repo has a bare `main`
|
|
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
|
|
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
|
|
if readme is None:
|
|
await self._gitea.create_file(
|
|
org,
|
|
content_repo,
|
|
"README.md",
|
|
content=readme_text,
|
|
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
|
|
branch="main",
|
|
author_name=actor.display_name,
|
|
author_email=ae,
|
|
)
|
|
result = await self._gitea.update_file(
|
|
org,
|
|
registry_repo,
|
|
"projects.yaml",
|
|
content=projects_yaml_new,
|
|
sha=projects_yaml_sha,
|
|
message=_stamp_single(f"chore: create project {project_id}", actor),
|
|
branch="main",
|
|
author_name=actor.display_name,
|
|
author_email=ae,
|
|
)
|
|
commit_sha = (
|
|
result.get("commit", {}).get("sha")
|
|
or result.get("content", {}).get("sha")
|
|
or ""
|
|
)
|
|
_log(
|
|
actor,
|
|
"create_project",
|
|
bot_commit_sha=commit_sha,
|
|
details={"project_id": project_id, "content_repo": content_repo},
|
|
)
|
|
return result
|
|
|
|
# ----- 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,
|
|
rfcs_dir: str = "rfcs",
|
|
) -> dict:
|
|
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
|
|
|
|
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>`. §22 S2:
|
|
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
|
|
propose into a named collection writes under its subfolder; it
|
|
defaults to `rfcs` (the default collection / shipped behaviour).
|
|
"""
|
|
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_dir}/{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,
|
|
)
|
|
|
|
# ----- Meta repo: metadata-pane PRs (§9.5) -----
|
|
|
|
async def open_metadata_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
new_file_contents: str,
|
|
prior_sha: str,
|
|
pr_title: str,
|
|
pr_description: str,
|
|
) -> dict:
|
|
"""Per §9.5: a metadata-pane edit (title or tags) on a super-draft
|
|
opens a tiny meta-repo PR that touches only the frontmatter of
|
|
`rfcs/<slug>.md`. One commit, one PR, easy to triage. The branch
|
|
name uses the dash-separated `metadata-<slug>-<6hex>` shape — same
|
|
routing-friendly form Slice 4 picked for edit branches per the
|
|
§19.2 path-routing candidate.
|
|
"""
|
|
import secrets
|
|
|
|
branch = f"metadata-{slug}-{secrets.token_hex(3)}"
|
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
|
commit_subject = pr_title
|
|
commit_message = _stamp_single(commit_subject, actor)
|
|
result = await self._gitea.update_file(
|
|
org,
|
|
meta_repo,
|
|
f"rfcs/{slug}.md",
|
|
content=new_file_contents,
|
|
sha=prior_sha,
|
|
message=commit_message,
|
|
branch=branch,
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
commit_sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
|
_subject, pr_body = _stamp("", pr_description, actor)
|
|
pr = await self._gitea.create_pull(
|
|
org,
|
|
meta_repo,
|
|
title=pr_title,
|
|
body=pr_body,
|
|
head=branch,
|
|
base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
"open_metadata_pr",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
pr_number=pr["number"],
|
|
bot_commit_sha=commit_sha,
|
|
details={"pr_title": pr_title},
|
|
)
|
|
return pr
|
|
|
|
# ----- Per-RFC repo: branches (§8.3, §8.14) -----
|
|
|
|
async def cut_branch_from_main(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
new_branch: str,
|
|
slug: str,
|
|
from_branch: str = "main",
|
|
) -> dict:
|
|
"""Per §8.14: 'Start Contributing' on main cuts a new branch.
|
|
|
|
Also covers the §8.3 case of a contributor wanting a fresh branch
|
|
for a piece of work. Returns the Gitea branch payload.
|
|
"""
|
|
created = await self._gitea.create_branch(owner, repo, new_branch, from_branch=from_branch)
|
|
_log(
|
|
actor,
|
|
"create_branch",
|
|
rfc_slug=slug,
|
|
branch_name=new_branch,
|
|
details={"from": from_branch, "repo": f"{owner}/{repo}"},
|
|
)
|
|
return created
|
|
|
|
# ----- Per-RFC repo: per-accepted-change commits (§8.6, §8.9) -----
|
|
|
|
async def commit_accepted_change(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
branch: str,
|
|
file_path: str,
|
|
new_content: str,
|
|
prior_sha: str,
|
|
change_id: int,
|
|
original: str,
|
|
proposed: str,
|
|
ai_proposed: str | None,
|
|
reason: str,
|
|
source_message_id: int | None,
|
|
slug: str,
|
|
) -> str:
|
|
"""Per §8.6: one commit per accepted change.
|
|
|
|
The commit message subject is a short structural description; the
|
|
body carries `original`, `proposed`, and `reason` in named
|
|
sections. When the contributor edited the AI's proposal before
|
|
accepting (§8.9's `was_edited_before_accept`), the AI's original
|
|
wording is preserved under an `AI proposed:` section so the
|
|
timeline records both what was offered and what landed.
|
|
|
|
Trailers: `Change-Id`, `Source-Message-Id` (where applicable),
|
|
and the standard `On-behalf-of:` per §6.5.
|
|
|
|
Returns the commit SHA.
|
|
"""
|
|
subject = _subject_from_reason(reason, fallback="Accept change")
|
|
body_lines = [
|
|
"**Original:**",
|
|
original.strip(),
|
|
"",
|
|
"**Proposed:**",
|
|
proposed.strip(),
|
|
]
|
|
if ai_proposed is not None and ai_proposed.strip() != proposed.strip():
|
|
body_lines += ["", "**AI proposed (edited before accept):**", ai_proposed.strip()]
|
|
if reason and reason.strip():
|
|
body_lines += ["", "**Reason:**", reason.strip()]
|
|
body_lines += ["", f"Change-Id: {change_id}"]
|
|
if source_message_id is not None:
|
|
body_lines += [f"Source-Message-Id: {source_message_id}"]
|
|
body_lines += [_trailer(actor)]
|
|
message = subject + "\n\n" + "\n".join(body_lines).strip()
|
|
|
|
result = await self._gitea.update_file(
|
|
owner,
|
|
repo,
|
|
file_path,
|
|
content=new_content,
|
|
sha=prior_sha,
|
|
message=message,
|
|
branch=branch,
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
|
_log(
|
|
actor,
|
|
"accept_change",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
bot_commit_sha=sha,
|
|
details={"change_id": change_id, "file_path": file_path},
|
|
)
|
|
return sha
|
|
|
|
# ----- Per-RFC repo: manual-edit flushes (§8.6, §8.11) -----
|
|
|
|
async def commit_manual_flush(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
branch: str,
|
|
file_path: str,
|
|
new_content: str,
|
|
prior_sha: str,
|
|
change_id: int,
|
|
paragraph_count: int,
|
|
slug: str,
|
|
) -> str:
|
|
"""Per §8.6 / §8.11: one commit per manual-edit flush window.
|
|
|
|
Subject names the structural extent so a reviewer scanning the
|
|
log can size the change at a glance; the body carries the
|
|
change-id trailer that binds the commit to the resolved card in
|
|
the panel.
|
|
"""
|
|
plural = "" if paragraph_count == 1 else "s"
|
|
subject = f"manual edit: {paragraph_count} paragraph{plural}"
|
|
body_lines = [
|
|
f"Change-Id: {change_id}",
|
|
_trailer(actor),
|
|
]
|
|
message = subject + "\n\n" + "\n".join(body_lines)
|
|
result = await self._gitea.update_file(
|
|
owner,
|
|
repo,
|
|
file_path,
|
|
content=new_content,
|
|
sha=prior_sha,
|
|
message=message,
|
|
branch=branch,
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
|
_log(
|
|
actor,
|
|
"manual_flush",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
bot_commit_sha=sha,
|
|
details={"change_id": change_id, "paragraph_count": paragraph_count},
|
|
)
|
|
return sha
|
|
|
|
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
|
|
|
# ----- Per-RFC repo: PRs (§10) -----
|
|
|
|
async def open_branch_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
head_branch: str,
|
|
title: str,
|
|
description: str,
|
|
slug: str,
|
|
supersedes_pr_number: int | None = None,
|
|
) -> dict:
|
|
"""Per §10.1: open a PR from a branch against main.
|
|
|
|
The PR's body carries the contributor's description, optional
|
|
`Supersedes:` trailer for the §10.9 replay path, and the
|
|
standard `On-behalf-of:` trailer per §6.5.
|
|
"""
|
|
body_lines = [description.strip()]
|
|
if supersedes_pr_number is not None:
|
|
body_lines += ["", f"Supersedes: #{supersedes_pr_number}"]
|
|
body_lines += ["", _trailer(actor)]
|
|
body = "\n".join(body_lines).strip()
|
|
pr = await self._gitea.create_pull(
|
|
owner,
|
|
repo,
|
|
title=title,
|
|
body=body,
|
|
head=head_branch,
|
|
base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
"open_branch_pr",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr["number"],
|
|
details={
|
|
"title": title,
|
|
"supersedes": supersedes_pr_number,
|
|
"repo": f"{owner}/{repo}",
|
|
},
|
|
)
|
|
return pr
|
|
|
|
async def merge_branch_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
head_branch: str,
|
|
slug: str,
|
|
) -> None:
|
|
"""Per §10.5: no-fast-forward merge.
|
|
|
|
Gitea's `style='merge'` produces a merge commit; the
|
|
per-acceptance commits from §8.6 remain individually reachable
|
|
in main's history. The merge commit's body records the merging
|
|
user via the `On-behalf-of:` trailer — the merge commit's
|
|
author stays the bot (the bot is the only Git writer per §1)
|
|
but the trailer carries the human accountability.
|
|
"""
|
|
subject = f"Merge branch '{head_branch}'"
|
|
body = _trailer(actor)
|
|
await self._gitea.merge_pull(
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
merge_message_title=subject,
|
|
merge_message_body=body,
|
|
style="merge",
|
|
)
|
|
_log(
|
|
actor,
|
|
"merge_branch_pr",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr_number,
|
|
details={"repo": f"{owner}/{repo}"},
|
|
)
|
|
|
|
async def withdraw_branch_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
head_branch: str,
|
|
slug: str,
|
|
reason: str = "withdraw",
|
|
) -> None:
|
|
"""Per §10.8: close the PR; do not delete the branch."""
|
|
await self._gitea.close_pull(owner, repo, pr_number)
|
|
_log(
|
|
actor,
|
|
"withdraw_branch_pr" if reason == "withdraw" else "supersede_branch_pr",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr_number,
|
|
details={"repo": f"{owner}/{repo}", "reason": reason},
|
|
)
|
|
|
|
async def cut_resolution_branch(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
original_branch: str,
|
|
resolution_branch: str,
|
|
slug: str,
|
|
) -> dict:
|
|
"""Per §10.9: cut a fresh branch off main's tip into which the
|
|
original branch's changes will be replayed. The bot owns the
|
|
cut; the replay itself is a sequence of commit_accepted_change
|
|
/ manual flush operations driven by the API layer."""
|
|
created = await self._gitea.create_branch(
|
|
owner, repo, resolution_branch, from_branch="main"
|
|
)
|
|
_log(
|
|
actor,
|
|
"create_resolution_branch",
|
|
rfc_slug=slug,
|
|
branch_name=resolution_branch,
|
|
details={
|
|
"repo": f"{owner}/{repo}",
|
|
"original_branch": original_branch,
|
|
},
|
|
)
|
|
return created
|
|
|
|
async def commit_replay_change(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
branch: str,
|
|
file_path: str,
|
|
new_content: str,
|
|
prior_sha: str,
|
|
original_change_id: int,
|
|
original: str,
|
|
proposed: str,
|
|
reason: str,
|
|
slug: str,
|
|
) -> str:
|
|
"""Per §10.9: a single replayed accept lands as its own commit on
|
|
the resolution branch, so the §8.6 evidence shape is preserved.
|
|
The subject mirrors `commit_accepted_change`'s but the body
|
|
records the original change id so the resolution PR's
|
|
conversation can stitch back to the original branch's chat."""
|
|
subject = _subject_from_reason(reason, fallback="Replay change")
|
|
body_lines = [
|
|
"**Original:**",
|
|
original.strip(),
|
|
"",
|
|
"**Proposed:**",
|
|
proposed.strip(),
|
|
]
|
|
if reason and reason.strip():
|
|
body_lines += ["", "**Reason:**", reason.strip()]
|
|
body_lines += ["", f"Replayed-Change-Id: {original_change_id}"]
|
|
body_lines += [_trailer(actor)]
|
|
message = subject + "\n\n" + "\n".join(body_lines).strip()
|
|
result = await self._gitea.update_file(
|
|
owner,
|
|
repo,
|
|
file_path,
|
|
content=new_content,
|
|
sha=prior_sha,
|
|
message=message,
|
|
branch=branch,
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
|
_log(
|
|
actor,
|
|
"replay_change",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
bot_commit_sha=sha,
|
|
details={"original_change_id": original_change_id, "file_path": file_path},
|
|
)
|
|
return sha
|
|
|
|
# ----- §13 graduation (meta-only): open + merge the flip PR -----
|
|
|
|
async def open_graduation_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
new_file_contents: str,
|
|
prior_sha: str,
|
|
rfc_id: str | None,
|
|
owners: list[str],
|
|
) -> dict:
|
|
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
|
entry's frontmatter to `state: active` with the graduation stamps
|
|
and — **optionally** — the integer `id`, **keeping the body
|
|
unchanged** (§1 meta-only topology; no repo is created and no body
|
|
is stripped). When `rfc_id` is None the entry graduates without a
|
|
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
|
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
|
the other meta-repo branches per the §19.2 path-routing candidate.
|
|
"""
|
|
import secrets
|
|
|
|
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
|
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
|
commit_message = _stamp_single(commit_subject, actor)
|
|
result = await self._gitea.update_file(
|
|
org, meta_repo, f"rfcs/{slug}.md",
|
|
content=new_file_contents,
|
|
sha=prior_sha,
|
|
message=commit_message,
|
|
branch=branch,
|
|
author_name=actor.display_name, author_email=ae,
|
|
)
|
|
commit_sha = (
|
|
result.get("commit", {}).get("sha")
|
|
or result.get("content", {}).get("sha")
|
|
or ""
|
|
)
|
|
pr_title = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
|
owners_str = ", ".join(owners) if owners else "(none)"
|
|
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
|
|
pr_body_text = (
|
|
f"Graduates super-draft `{slug}` to active.\n\n"
|
|
f"{id_line}"
|
|
f"- Owners: {owners_str}\n\n"
|
|
f"This is an in-place state flip per the meta-only topology\n"
|
|
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
|
|
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
|
|
f"`id`, and the graduation stamps."
|
|
)
|
|
_subject, pr_body = _stamp("", pr_body_text, actor)
|
|
pr = await self._gitea.create_pull(
|
|
org, meta_repo,
|
|
title=pr_title, body=pr_body, head=branch, base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
"graduate_pr_open",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
pr_number=pr["number"],
|
|
bot_commit_sha=commit_sha,
|
|
details={"pr_title": pr_title, "rfc_id": rfc_id},
|
|
)
|
|
return pr
|
|
|
|
async def merge_graduation_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
head_branch: str,
|
|
slug: str,
|
|
rfc_id: str | None,
|
|
) -> None:
|
|
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
|
merge actor. Distinct action_kind so the audit log carries the
|
|
graduation as a linkable sequence per §13.3's transactional shape.
|
|
|
|
Gitea computes a PR's mergeability asynchronously in a background
|
|
job. Step 3 (`open_graduation_pr`) returns the moment the PR is
|
|
created, which is typically before that job has finished — calling
|
|
`merge_pull` immediately produces a `405 "Please try again later"`
|
|
and the §13.3 orchestrator interprets it as a terminal failure.
|
|
We wait for the mergeability computation to settle before the
|
|
merge call, then retry the merge a couple of times if Gitea still
|
|
reports the transient state (belt-and-suspenders against the same
|
|
race appearing in a different shape).
|
|
"""
|
|
subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
|
body = _trailer(actor)
|
|
|
|
try:
|
|
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
|
except TimeoutError as e:
|
|
raise GiteaError(409, str(e)) from e
|
|
|
|
await _merge_with_retry(
|
|
self._gitea, org, meta_repo, pr_number,
|
|
merge_message_title=subject, merge_message_body=body,
|
|
)
|
|
_log(
|
|
actor,
|
|
"graduate_pr_merge",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr_number,
|
|
details={"rfc_id": rfc_id},
|
|
)
|
|
|
|
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
|
|
|
|
async def open_retire_flip_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
new_file_contents: str,
|
|
prior_sha: str,
|
|
verb: str,
|
|
target_state: str,
|
|
) -> dict:
|
|
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
|
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
|
or un-retire (`verb='unretire'`, target the restored prior state).
|
|
Only the frontmatter `state` changes; the body and every other
|
|
field (including the integer `id`) are kept, so an un-retire
|
|
restores the entry exactly. Branch shape mirrors graduation's
|
|
`<verb>-<slug>-<6hex>`.
|
|
"""
|
|
import secrets
|
|
|
|
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
|
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
|
commit_subject = f"{verb_title} {slug}"
|
|
commit_message = _stamp_single(commit_subject, actor)
|
|
result = await self._gitea.update_file(
|
|
org, meta_repo, f"rfcs/{slug}.md",
|
|
content=new_file_contents,
|
|
sha=prior_sha,
|
|
message=commit_message,
|
|
branch=branch,
|
|
author_name=actor.display_name, author_email=ae,
|
|
)
|
|
commit_sha = (
|
|
result.get("commit", {}).get("sha")
|
|
or result.get("content", {}).get("sha")
|
|
or ""
|
|
)
|
|
pr_title = f"{verb_title} {slug}"
|
|
pr_body_text = (
|
|
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
|
|
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
|
|
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
|
|
f"only `state` changes."
|
|
)
|
|
_subject, pr_body = _stamp("", pr_body_text, actor)
|
|
pr = await self._gitea.create_pull(
|
|
org, meta_repo,
|
|
title=pr_title, body=pr_body, head=branch, base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
f"{verb}_pr_open",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
pr_number=pr["number"],
|
|
bot_commit_sha=commit_sha,
|
|
details={"pr_title": pr_title, "target_state": target_state},
|
|
)
|
|
return pr
|
|
|
|
async def merge_retire_flip_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
head_branch: str,
|
|
slug: str,
|
|
verb: str,
|
|
) -> None:
|
|
"""§13.7: auto-merge the retire / un-retire flip PR (same
|
|
mergeable-wait + retry shape as `merge_graduation_pr`)."""
|
|
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
|
subject = f"{verb_title} {slug}"
|
|
body = _trailer(actor)
|
|
try:
|
|
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
|
except TimeoutError as e:
|
|
raise GiteaError(409, str(e)) from e
|
|
await _merge_with_retry(
|
|
self._gitea, org, meta_repo, pr_number,
|
|
merge_message_title=subject, merge_message_body=body,
|
|
)
|
|
_log(
|
|
actor,
|
|
f"{verb}_pr_merge",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr_number,
|
|
details={},
|
|
)
|
|
|
|
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
|
|
|
async def close_graduation_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
head_branch: str,
|
|
slug: str,
|
|
reason: str,
|
|
) -> None:
|
|
"""Undo of `open_graduation_pr`. Closes the PR without merging.
|
|
The companion `delete_branch` call lives next to the rollback
|
|
caller in `api_graduation.py` per the §19.2 'graduation rollback's
|
|
branch cleanup' candidate Slice 8 settles — the §12 hygiene
|
|
sweep would catch the branch eventually, but closing the loop
|
|
on rollback avoids accumulation."""
|
|
await self._gitea.close_pull(org, meta_repo, pr_number)
|
|
_log(
|
|
actor,
|
|
"graduate_pr_close",
|
|
rfc_slug=slug,
|
|
branch_name=head_branch,
|
|
pr_number=pr_number,
|
|
details={"reason": reason},
|
|
)
|
|
|
|
# ----- §12 hygiene: branch deletion -----
|
|
|
|
async def delete_branch(
|
|
self,
|
|
actor: Actor | None,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
branch: str,
|
|
slug: str | None,
|
|
action_kind: str,
|
|
reason: str,
|
|
bot_login: str | None = None,
|
|
) -> None:
|
|
"""Per §12: the bot deletes a stale branch from Gitea.
|
|
|
|
Three callers: the §12 hygiene sweep (90-day boundary on
|
|
meta-repo edit branches), the §10.7 90-day post-merge timer
|
|
for per-RFC PR branches, and the graduation-rollback cleanup
|
|
for `graduate-<slug>-<6hex>` per §19.2 "graduation rollback's
|
|
branch cleanup."
|
|
|
|
For the timer paths the caller passes `actor=None`; the audit
|
|
row lands with `actor_user_id=NULL` and `on_behalf_of=bot_login`
|
|
per §15.9's "system-generated events" rule — "the app" in the
|
|
noun slot. For the rollback case the human actor flows through
|
|
the standard `_log` shape.
|
|
|
|
Idempotent against the Gitea API — 404 from a prior delete is
|
|
swallowed so a retried sweep doesn't crash.
|
|
"""
|
|
try:
|
|
await self._gitea.delete_branch(owner, repo, branch)
|
|
except Exception as exc:
|
|
from .gitea import GiteaError as _GE
|
|
if isinstance(exc, _GE) and exc.status == 404:
|
|
pass
|
|
else:
|
|
raise
|
|
details = {"repo": f"{owner}/{repo}", "reason": reason}
|
|
if actor is None:
|
|
# System actor: write the audit row directly. Fan-out is
|
|
# skipped — `delete_stale_branch` and `delete_post_merge_branch`
|
|
# are intentionally absent from `notify._AUTO_WATCH_ACTIONS`
|
|
# and `_ROUTING`, so no notification fires. The branches
|
|
# being deleted are stale; the population that watched them
|
|
# would be churn-grade noise per §15.4.
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO actions
|
|
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, details)
|
|
VALUES (NULL, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
bot_login or "",
|
|
action_kind,
|
|
slug,
|
|
branch,
|
|
json.dumps(details),
|
|
),
|
|
)
|
|
return
|
|
_log(
|
|
actor,
|
|
action_kind,
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
details=details,
|
|
)
|
|
|
|
# ----- §13.1 claim PRs -----
|
|
|
|
async def open_claim_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
new_file_contents: str,
|
|
prior_sha: str,
|
|
) -> dict:
|
|
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
|
|
|
|
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
|
|
`claim/<slug>` — single attempt per super-draft per actor (Gitea
|
|
refuses duplicate branch creation, which is the right behavior:
|
|
if the claim is still open, point the contributor at the existing
|
|
PR rather than opening a second one).
|
|
"""
|
|
branch = f"claim/{slug}"
|
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
|
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
|
|
commit_message = _stamp_single(commit_subject, actor)
|
|
result = await self._gitea.update_file(
|
|
org, meta_repo, f"rfcs/{slug}.md",
|
|
content=new_file_contents,
|
|
sha=prior_sha,
|
|
message=commit_message,
|
|
branch=branch,
|
|
author_name=actor.display_name, author_email=ae,
|
|
)
|
|
commit_sha = (
|
|
result.get("commit", {}).get("sha")
|
|
or result.get("content", {}).get("sha")
|
|
or ""
|
|
)
|
|
pr_title = f"Claim ownership: {slug}"
|
|
pr_description = (
|
|
f"`{actor.gitea_login}` claims ownership of super-draft `{slug}`.\n\n"
|
|
f"Per §13.1, owners and admins can merge."
|
|
)
|
|
_subject, pr_body = _stamp("", pr_description, actor)
|
|
pr = await self._gitea.create_pull(
|
|
org, meta_repo,
|
|
title=pr_title, body=pr_body, head=branch, base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
"open_claim_pr",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
pr_number=pr["number"],
|
|
bot_commit_sha=commit_sha,
|
|
details={"new_owner": actor.gitea_login},
|
|
)
|
|
return pr
|
|
|
|
# ----- §22.4c: mark-reviewed (direct main write) -----
|
|
|
|
async def mark_entry_reviewed(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
reviewed_by: str,
|
|
reviewed_at: str,
|
|
) -> None:
|
|
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
|
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
|
trailer and writes an actions-log row, mirroring the graduation
|
|
stamp's bot-write shape."""
|
|
path = f"rfcs/{slug}.md"
|
|
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
|
if result is None:
|
|
raise GiteaError(404, f"{path} not found")
|
|
text, sha = result
|
|
e = entry_mod.parse(text)
|
|
e.unreviewed = False
|
|
e.reviewed_at = reviewed_at
|
|
e.reviewed_by = reviewed_by
|
|
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
|
result = await self._gitea.update_file(
|
|
org, meta_repo, path,
|
|
content=entry_mod.serialize(e),
|
|
sha=sha,
|
|
message=commit_message,
|
|
branch="main",
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
commit_sha = (
|
|
result.get("commit", {}).get("sha")
|
|
or result.get("content", {}).get("sha")
|
|
or ""
|
|
)
|
|
_log(
|
|
actor,
|
|
"mark_reviewed",
|
|
rfc_slug=slug,
|
|
bot_commit_sha=commit_sha,
|
|
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
|
|
)
|
|
|
|
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
|
|
|
async def ensure_rfc_repo_seed(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
slug: str,
|
|
title: str,
|
|
body: str,
|
|
) -> None:
|
|
"""Create the per-RFC repo and seed `RFC.md` on `main` if missing.
|
|
|
|
Slice 2 surfaces against per-RFC repos that Slice 5's graduation
|
|
flow will eventually create. Until graduation exists, this is the
|
|
seam test fixtures and ad-hoc dev workflows use to bring an RFC
|
|
repo into existence — the bot stays the only Git writer and the
|
|
seed itself enters the audit log.
|
|
"""
|
|
existing = await self._gitea.get_repo(owner, repo)
|
|
if existing is None:
|
|
await self._gitea.create_org_repo(owner, repo, description=f"RFC: {title}")
|
|
# If main has a tip already, leave it alone — the seed is idempotent.
|
|
main = await self._gitea.get_branch(owner, repo, "main")
|
|
if main is not None:
|
|
return
|
|
message = "Seed RFC.md\n\n" + _trailer(actor)
|
|
await self._gitea.create_file(
|
|
owner,
|
|
repo,
|
|
"RFC.md",
|
|
content=body,
|
|
message=message,
|
|
branch="main",
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
_log(
|
|
actor,
|
|
"seed_rfc_repo",
|
|
rfc_slug=slug,
|
|
branch_name="main",
|
|
details={"repo": f"{owner}/{repo}", "title": title},
|
|
)
|
|
|
|
|
|
def _subject_from_reason(reason: str, fallback: str) -> str:
|
|
"""One-line commit subject derived from the change's reason.
|
|
|
|
Truncated to 72 chars so the Git log scans cleanly. Exact length is
|
|
an implementation detail per §8.6.
|
|
"""
|
|
text = (reason or "").strip().split("\n")[0]
|
|
if not text:
|
|
return fallback
|
|
if len(text) > 72:
|
|
return text[:69].rstrip() + "…"
|
|
return text
|