4565a6cb95
The §17 routing-collapse rule lands in api_branches.py and api_prs.py — every branches/<branch>/... and prs/<n>/... route dispatches on the entry's state to pick the right Gitea repo, and the body extracted from the entry's frontmatter envelope is what the editor and the diff see. The bot grows open_metadata_pr; cache grows refresh_meta_branches. Two §17 routes added: start-edit-branch and metadata. The §9.4 super-draft view replaces RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to dodge the §19.2 path-routing candidate while preserving §9.5's structural shape. Covered by tests/test_super_draft_vertical.py (10 tests). The full Slices 1-4 suite is 35/35 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
689 lines
22 KiB
Python
689 lines
22 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 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,
|
|
)
|
|
|
|
# ----- 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
|
|
|
|
# ----- 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
|