Files
basicmachines-co-basic-memory/scripts/bm_bossbot_status.py
phernandez d8c9156ffd feat(ci): make BM Bossbot a deterministic merge gate
Remove the Codex LLM review and the per-PR image job — both spent API
tokens on every Bossbot run and the connector review already covers
code review. The gate is now fully deterministic: Tests passed for the
head SHA, non-draft, trusted author, zero unresolved review threads.
Prompt/schema files removed; guard tests updated to forbid
openai/codex-action and image generation from reappearing.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-10 09:43:47 -05:00

535 lines
17 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "typer>=0.9.0",
# ]
# ///
"""BM Bossbot status and PR-body helpers.
BM Bossbot is a deterministic merge gate — no LLM review. It approves a head
SHA only when the Tests workflow succeeded for it (enforced by the workflow
trigger), the PR is not a draft, the author is trusted, and every review
thread is resolved. Code review itself comes from the Codex connector and
human reviewers; this gate just refuses to let unaddressed feedback merge.
"""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any, Mapping
import typer
STATUS_CONTEXT = "BM Bossbot Approval"
SUMMARY_START = "<!-- BM_BOSSBOT_SUMMARY:start -->"
SUMMARY_END = "<!-- BM_BOSSBOT_SUMMARY:end -->"
APPROVED_DESCRIPTION = "BM Bossbot approved this head SHA"
PENDING_DESCRIPTION = "BM Bossbot is reviewing this head SHA"
app = typer.Typer(
add_completion=False,
help="Manage deterministic BM Bossbot PR approval statuses.",
no_args_is_help=True,
)
@dataclass(frozen=True)
class ApprovalResult:
approved: bool
state: str
description: str
@dataclass(frozen=True)
class PullRequestEvent:
repo: str
number: int
head_sha: str
body: str
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise SystemExit(f"Missing JSON file: {path}") from None
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}: invalid JSON: {exc}") from None
def pull_request_event(
payload: Mapping[str, Any], repo_override: str | None = None
) -> PullRequestEvent:
pr = payload.get("pull_request")
if not isinstance(pr, Mapping):
raise SystemExit("GitHub event payload is missing pull_request")
repo = repo_override
if repo is None:
repository = payload.get("repository")
if isinstance(repository, Mapping):
repo = _string(repository.get("full_name"))
if not repo:
raise SystemExit("Could not determine GitHub repository")
number = pr.get("number")
if not isinstance(number, int):
raise SystemExit("GitHub event payload is missing pull_request.number")
head = pr.get("head")
head_sha = (
_string(head.get("sha")) if isinstance(head, Mapping) else _string(pr.get("head_sha"))
)
if not head_sha:
raise SystemExit("GitHub event payload is missing pull_request.head.sha")
return PullRequestEvent(
repo=repo,
number=number,
head_sha=head_sha,
body=_string(pr.get("body")),
)
def count_unresolved_review_threads(*, token: str, repo: str, number: int) -> int:
"""Count unresolved review threads (e.g. open Codex inline comments) on a PR.
Review threads are the canonical 'outstanding feedback' signal: bot reviewers
submit COMMENTED reviews that never flip reviewDecision, so thread resolution
is the only deterministic way to know feedback was addressed.
"""
owner, _, name = repo.partition("/")
if not owner or not name:
raise SystemExit(f"Invalid repository: {repo}")
query = """
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { isResolved }
}
}
}
}
"""
unresolved = 0
cursor: str | None = None
while True:
response = _github_request(
method="POST",
path="/graphql",
token=token,
payload={
"query": query,
"variables": {"owner": owner, "name": name, "number": number, "cursor": cursor},
},
)
if not isinstance(response, Mapping) or response.get("errors"):
raise SystemExit(f"GitHub GraphQL reviewThreads query failed: {response}")
try:
threads = response["data"]["repository"]["pullRequest"]["reviewThreads"]
nodes = threads["nodes"]
page_info = threads["pageInfo"]
except (KeyError, TypeError):
raise SystemExit(
"GitHub GraphQL reviewThreads response was missing expected fields"
) from None
unresolved += sum(1 for node in nodes if not node.get("isResolved"))
if not page_info.get("hasNextPage"):
return unresolved
cursor = page_info.get("endCursor")
def unresolved_threads_result(count: int) -> ApprovalResult:
return ApprovalResult(
False,
"failure",
f"BM Bossbot found {count} unresolved review thread(s)",
)
def evaluate_gate(
*,
token: str,
repo: str,
number: int,
trusted: bool,
) -> tuple[ApprovalResult, int]:
"""Deterministic approval decision: trusted author + zero unresolved threads.
Tests-passed-for-this-head and non-draft are enforced upstream by the
workflow trigger and the normalize step (should_review). Returns the
result plus the unresolved-thread count for the PR-body summary.
"""
if not trusted:
return (
ApprovalResult(
False,
"failure",
"BM Bossbot only gates owner/member/collaborator PRs",
),
0,
)
unresolved = count_unresolved_review_threads(token=token, repo=repo, number=number)
if unresolved > 0:
return unresolved_threads_result(unresolved), unresolved
return ApprovalResult(True, "success", APPROVED_DESCRIPTION), 0
def build_status_payload(*, state: str, description: str, target_url: str) -> dict[str, str]:
return {
"state": state,
"context": STATUS_CONTEXT,
"description": description,
"target_url": target_url,
}
def render_summary(
*,
head_sha: str,
result: ApprovalResult,
trusted: bool,
unresolved_threads: int,
) -> str:
return "\n".join(
[
f"Reviewed SHA: `{head_sha}`",
"Gate: deterministic (tests, draft, author trust, review threads)",
f"Status: `{result.state}` - {result.description}",
"",
f"- Trusted author: {'yes' if trusted else 'no'}",
f"- Unresolved review threads: {unresolved_threads}",
"",
"Code review comes from the Codex connector and human reviewers;",
"resolve every review thread to (re)gain approval for this head SHA.",
]
)
def upsert_summary_block(body: str, summary: str) -> str:
block = f"{SUMMARY_START}\n{summary.rstrip()}\n{SUMMARY_END}"
pattern = re.compile(
rf"{re.escape(SUMMARY_START)}.*?{re.escape(SUMMARY_END)}",
flags=re.DOTALL,
)
if pattern.search(body):
return pattern.sub(block, body, count=1)
if body.strip():
return f"{body.rstrip()}\n\n{block}\n"
return f"{block}\n"
def set_commit_status(*, token: str, repo: str, sha: str, payload: Mapping[str, str]) -> None:
_github_request(
method="POST",
path=f"/repos/{repo}/statuses/{sha}",
token=token,
payload=payload,
)
def update_pull_request_body(*, token: str, repo: str, number: int, body: str) -> None:
_github_request(
method="PATCH",
path=f"/repos/{repo}/pulls/{number}",
token=token,
payload={"body": body},
)
def get_pull_request_body(*, token: str, repo: str, number: int) -> str:
response = _github_request(
method="GET",
path=f"/repos/{repo}/pulls/{number}",
token=token,
)
if not isinstance(response, Mapping):
raise SystemExit("GitHub API response for pull request was invalid")
return _string(response.get("body"))
def mark_pending(
*,
event_path: Path,
repo: str | None,
run_url: str,
token_env: str,
) -> None:
event = pull_request_event(read_json(event_path), repo_override=repo)
set_commit_status(
token=_token(token_env),
repo=event.repo,
sha=event.head_sha,
payload=build_status_payload(
state="pending",
description=PENDING_DESCRIPTION,
target_url=run_url,
),
)
typer.echo(f"Marked {STATUS_CONTEXT} pending for {event.head_sha}")
def finalize_review(
*,
event_path: Path,
trusted: bool,
repo: str | None,
run_url: str,
token_env: str,
) -> ApprovalResult:
event = pull_request_event(read_json(event_path), repo_override=repo)
token = _token(token_env)
result, unresolved = evaluate_gate(
token=token, repo=event.repo, number=event.number, trusted=trusted
)
current_body = get_pull_request_body(token=token, repo=event.repo, number=event.number)
updated_body = upsert_summary_block(
current_body,
render_summary(
head_sha=event.head_sha,
result=result,
trusted=trusted,
unresolved_threads=unresolved,
),
)
update_pull_request_body(token=token, repo=event.repo, number=event.number, body=updated_body)
set_commit_status(
token=token,
repo=event.repo,
sha=event.head_sha,
payload=build_status_payload(
state=result.state,
description=result.description,
target_url=run_url,
),
)
typer.echo(f"Marked {STATUS_CONTEXT} {result.state} for {event.head_sha}")
return result
def get_pull_request_head_sha(*, token: str, repo: str, number: int) -> str:
response = _github_request(
method="GET",
path=f"/repos/{repo}/pulls/{number}",
token=token,
)
if not isinstance(response, Mapping):
raise SystemExit("GitHub API response for pull request was invalid")
head = response.get("head")
head_sha = _string(head.get("sha")) if isinstance(head, Mapping) else ""
if not head_sha:
raise SystemExit("GitHub API response was missing pull request head SHA")
return head_sha
def head_sha_was_approved(*, token: str, repo: str, sha: str) -> bool:
"""Return whether a full BM Bossbot review previously approved this head SHA.
Commit statuses are append-only history, so the approval record survives a
later thread-failure status for the same SHA. The recheck path can post a
new status on every review-thread event, so a busy PR can accumulate more
than one page of statuses — page through all of them or the approval
record falls off page one and a valid approval is never restored.
"""
page = 1
while True:
response = _github_request(
method="GET",
path=f"/repos/{repo}/commits/{sha}/statuses?per_page=100&page={page}",
token=token,
)
if not isinstance(response, list):
raise SystemExit("GitHub API response for commit statuses was invalid")
if not response:
return False
if any(
isinstance(status, Mapping)
and status.get("context") == STATUS_CONTEXT
and status.get("state") == "success"
and status.get("description") == APPROVED_DESCRIPTION
for status in response
):
return True
page += 1
def recheck_threads(
*,
repo: str,
number: int,
run_url: str,
token_env: str,
) -> None:
"""Re-evaluate the approval status when review threads change.
Trigger: pull_request_review / review_comment / review_thread events.
Why: the full review runs once per head SHA after Tests; feedback that
arrives later (or gets resolved later) must move the gate without
re-running the LLM review.
Outcome: unresolved threads flip the status to failure; once every thread
is resolved, a previously earned approval for the same head SHA is
restored. Without a prior approval the status is left untouched so a
pending/failed review cannot be upgraded by thread resolution alone.
"""
token = _token(token_env)
head_sha = get_pull_request_head_sha(token=token, repo=repo, number=number)
unresolved = count_unresolved_review_threads(token=token, repo=repo, number=number)
if unresolved > 0:
result = unresolved_threads_result(unresolved)
elif head_sha_was_approved(token=token, repo=repo, sha=head_sha):
result = ApprovalResult(True, "success", APPROVED_DESCRIPTION)
else:
typer.echo(
f"All review threads resolved but no prior approval exists for {head_sha}; "
"leaving status unchanged"
)
return
set_commit_status(
token=token,
repo=repo,
sha=head_sha,
payload=build_status_payload(
state=result.state,
description=result.description,
target_url=run_url,
),
)
typer.echo(f"Marked {STATUS_CONTEXT} {result.state} for {head_sha} ({result.description})")
def _github_request(
*,
method: str,
path: str,
token: str,
payload: Mapping[str, Any] | None = None,
) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
f"https://api.github.com{path}",
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "basic-memory-bm-bossbot",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
response_body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"GitHub API request failed: {exc.code} {detail}") from None
return json.loads(response_body) if response_body else None
def _string(value: object) -> str:
return value if isinstance(value, str) else ""
def _token(env_name: str) -> str:
token = os.environ.get(env_name)
if not token:
raise SystemExit(f"Missing required token environment variable: {env_name}")
return token
@app.command("pending")
def pending(
event: Annotated[
Path,
typer.Option(
"--event",
exists=True,
dir_okay=False,
readable=True,
help="GitHub event payload JSON.",
),
],
run_url: Annotated[str, typer.Option("--run-url", help="Workflow run URL.")],
repo: Annotated[str | None, typer.Option("--repo", help="owner/name repository.")] = None,
token_env: Annotated[
str,
typer.Option("--token-env", help="Environment variable containing a GitHub token."),
] = "GITHUB_TOKEN",
) -> None:
"""Set BM Bossbot Approval pending on the PR head SHA."""
mark_pending(event_path=event, repo=repo, run_url=run_url, token_env=token_env)
@app.command("finalize")
def finalize(
event: Annotated[
Path,
typer.Option(
"--event",
exists=True,
dir_okay=False,
readable=True,
help="GitHub event payload JSON.",
),
],
trusted: Annotated[
str,
typer.Option(
"--trusted",
help="Whether the PR author is trusted (true/false from the classify step).",
),
],
run_url: Annotated[str, typer.Option("--run-url", help="Workflow run URL.")],
repo: Annotated[str | None, typer.Option("--repo", help="owner/name repository.")] = None,
token_env: Annotated[
str,
typer.Option("--token-env", help="Environment variable containing a GitHub token."),
] = "GITHUB_TOKEN",
) -> None:
"""Finalize BM Bossbot Approval from the deterministic gate."""
result = finalize_review(
event_path=event,
trusted=trusted.strip().lower() == "true",
repo=repo,
run_url=run_url,
token_env=token_env,
)
if not result.approved:
raise typer.Exit(1)
@app.command("recheck")
def recheck(
pr_number: Annotated[int, typer.Option("--pr-number", min=1, help="Pull request number.")],
run_url: Annotated[str, typer.Option("--run-url", help="Workflow run URL.")],
repo: Annotated[str, typer.Option("--repo", help="owner/name repository.")],
token_env: Annotated[
str,
typer.Option("--token-env", help="Environment variable containing a GitHub token."),
] = "GITHUB_TOKEN",
) -> None:
"""Re-evaluate BM Bossbot Approval from current review-thread state."""
recheck_threads(repo=repo, number=pr_number, run_url=run_url, token_env=token_env)
def main() -> None:
app()
if __name__ == "__main__":
main()