|
| 1 | +"""Final check on an important pull request, posted as TechEngineBot. |
| 2 | +
|
| 3 | +TechEngineBot is a real account and was suspended once for spam, so it keeps |
| 4 | +its standing by speaking rarely: only on PRs a person marks with the |
| 5 | +`final-check` label, as one comment per PR that later runs edit in place, plus |
| 6 | +one commit status. A status can be made a required check; neither edits nor |
| 7 | +statuses notify anyone. |
| 8 | +
|
| 9 | +The verdict only reads what GitHub already knows — it never checks out or |
| 10 | +runs the PR's code, so it is safe under `pull_request_target`. |
| 11 | +
|
| 12 | +Run with: python -m machine.final_check --repo OWNER/NAME --pr N [--publish] |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import argparse |
| 18 | +import json |
| 19 | +import os |
| 20 | +import sys |
| 21 | +import time |
| 22 | +import urllib.request |
| 23 | +from dataclasses import dataclass, field |
| 24 | +from typing import Any |
| 25 | + |
| 26 | +from machine.health import API, HEALTHY, _get, _get_text, foreign_secrets |
| 27 | + |
| 28 | +MARKER = "<!-- techenginebot:final-check -->" |
| 29 | +CONTEXT = "final-check" |
| 30 | +# git-flow: main only moves through a release from develop, or a hotfix. |
| 31 | +RELEASE_SOURCES = ("develop", "hotfix/") |
| 32 | + |
| 33 | + |
| 34 | +@dataclass |
| 35 | +class Verdict: |
| 36 | + problems: list[str] = field(default_factory=list) |
| 37 | + pending: list[str] = field(default_factory=list) |
| 38 | + passed: list[str] = field(default_factory=list) |
| 39 | + |
| 40 | + @property |
| 41 | + def state(self) -> str: |
| 42 | + if self.problems: |
| 43 | + return "failure" |
| 44 | + return "pending" if self.pending else "success" |
| 45 | + |
| 46 | + |
| 47 | +def evaluate(pr: dict[str, Any], check_runs: list[dict[str, Any]], statuses: list[dict[str, Any]], |
| 48 | + workflow_texts: dict[str, str], allowed: set[str] = frozenset()) -> Verdict: |
| 49 | + v = Verdict() |
| 50 | + |
| 51 | + if pr.get("draft"): |
| 52 | + v.problems.append("the PR is still a draft") |
| 53 | + |
| 54 | + base, head = pr["base"]["ref"], pr["head"]["ref"] |
| 55 | + if base == "main" and not head.startswith(RELEASE_SOURCES): |
| 56 | + v.problems.append(f"`{head}` → `main`: main only takes a release from develop or a hotfix") |
| 57 | + else: |
| 58 | + v.passed.append(f"`{head}` → `{base}` follows git-flow") |
| 59 | + |
| 60 | + if pr.get("mergeable") is False: |
| 61 | + v.problems.append("merge conflicts with the base branch") |
| 62 | + |
| 63 | + # Our own job and status would otherwise wait on themselves. |
| 64 | + runs = [r for r in check_runs if CONTEXT not in r.get("name", "")] |
| 65 | + others = [s for s in statuses if s.get("context") != CONTEXT] |
| 66 | + unsettled = len(v.problems) + len(v.pending) |
| 67 | + for run in runs: |
| 68 | + if run.get("status") != "completed": |
| 69 | + v.pending.append(f"check `{run['name']}` is still running") |
| 70 | + elif run.get("conclusion") not in HEALTHY: |
| 71 | + v.problems.append(f"check `{run['name']}`: {run.get('conclusion')}") |
| 72 | + for status in others: |
| 73 | + if status.get("state") == "pending": |
| 74 | + v.pending.append(f"status `{status['context']}` is pending") |
| 75 | + elif status.get("state") != "success": |
| 76 | + v.problems.append(f"status `{status['context']}`: {status.get('state')}") |
| 77 | + if not runs and not others: |
| 78 | + # Passing a commit nothing has looked at would be a silent failure of its own. |
| 79 | + v.problems.append("no other check ran on this commit — nothing to confirm") |
| 80 | + elif len(v.problems) + len(v.pending) == unsettled: |
| 81 | + v.passed.append(f"{len(runs) + len(others)} other check(s) passed") |
| 82 | + |
| 83 | + for path, text in sorted(workflow_texts.items()): |
| 84 | + names = foreign_secrets(text, allowed) |
| 85 | + if names: |
| 86 | + v.problems.append(f"`{path}` uses {', '.join(names)} — automation must not run as a person") |
| 87 | + if workflow_texts and not any("automation must not" in p for p in v.problems): |
| 88 | + v.passed.append("changed workflows use only GITHUB_TOKEN") |
| 89 | + return v |
| 90 | + |
| 91 | + |
| 92 | +def render(v: Verdict, sha: str) -> str: |
| 93 | + title = {"success": "passed", "failure": "not ready", "pending": "waiting on checks"}[v.state] |
| 94 | + lines = [MARKER, f"### Final check: {title}", "", f"Checked `{sha[:7]}`.", ""] |
| 95 | + lines += [f"- ❌ {p}" for p in v.problems] |
| 96 | + lines += [f"- ⏳ {p}" for p in v.pending] |
| 97 | + lines += [f"- ✅ {p}" for p in v.passed] |
| 98 | + lines += ["", "<sub>Runs when the `final-check` label is on the PR; this comment is updated, not repeated.</sub>"] |
| 99 | + return "\n".join(lines) + "\n" |
| 100 | + |
| 101 | + |
| 102 | +def _call(method: str, path: str, token: str, payload: dict[str, Any]) -> Any: |
| 103 | + request = urllib.request.Request(f"{API}{path}", data=json.dumps(payload).encode("utf-8"), |
| 104 | + method=method, headers={ |
| 105 | + "Accept": "application/vnd.github+json", |
| 106 | + "Authorization": f"Bearer {token}", |
| 107 | + "Content-Type": "application/json", |
| 108 | + }) |
| 109 | + with urllib.request.urlopen(request, timeout=30) as response: |
| 110 | + return json.loads(response.read().decode("utf-8") or "null") |
| 111 | + |
| 112 | + |
| 113 | +def _paged(url: str, token: str | None, key: str | None = None) -> list[dict[str, Any]]: |
| 114 | + items: list[dict[str, Any]] = [] |
| 115 | + for page in range(1, 11): |
| 116 | + data = _get(f"{url}{'&' if '?' in url else '?'}per_page=100&page={page}", token) |
| 117 | + batch = data[key] if key else data |
| 118 | + items.extend(batch) |
| 119 | + if len(batch) < 100: |
| 120 | + break |
| 121 | + return items |
| 122 | + |
| 123 | + |
| 124 | +def gather(repo: str, number: int, token: str | None, allowed: set[str]) -> tuple[dict[str, Any], Verdict]: |
| 125 | + pr = _get(f"{API}/repos/{repo}/pulls/{number}", token) |
| 126 | + sha = pr["head"]["sha"] |
| 127 | + runs = _paged(f"{API}/repos/{repo}/commits/{sha}/check-runs", token, "check_runs") |
| 128 | + statuses = _get(f"{API}/repos/{repo}/commits/{sha}/status", token).get("statuses", []) |
| 129 | + files = _paged(f"{API}/repos/{repo}/pulls/{number}/files", token) |
| 130 | + workflows = { |
| 131 | + f["filename"]: _get_text(f"{API}/repos/{repo}/contents/{f['filename']}?ref={sha}", token) |
| 132 | + for f in files |
| 133 | + if f["filename"].startswith(".github/workflows/") and f.get("status") != "removed" |
| 134 | + } |
| 135 | + return pr, evaluate(pr, runs, statuses, workflows, allowed) |
| 136 | + |
| 137 | + |
| 138 | +def publish(repo: str, number: int, sha: str, body: str, state: str, token: str) -> None: |
| 139 | + """One comment per PR, edited in place, and one commit status.""" |
| 140 | + comments = _paged(f"{API}/repos/{repo}/issues/{number}/comments", token) |
| 141 | + existing = next((c for c in comments if MARKER in (c.get("body") or "")), None) |
| 142 | + if existing: |
| 143 | + comment = _call("PATCH", f"/repos/{repo}/issues/comments/{existing['id']}", token, {"body": body}) |
| 144 | + else: |
| 145 | + comment = _call("POST", f"/repos/{repo}/issues/{number}/comments", token, {"body": body}) |
| 146 | + description = {"success": "Ready to merge", "failure": "Not ready — see the comment", |
| 147 | + "pending": "Waiting on other checks"}[state] |
| 148 | + _call("POST", f"/repos/{repo}/statuses/{sha}", token, { |
| 149 | + "state": state, "context": CONTEXT, "description": description, |
| 150 | + "target_url": comment.get("html_url", ""), |
| 151 | + }) |
| 152 | + |
| 153 | + |
| 154 | +def main(argv: list[str] | None = None) -> int: |
| 155 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 156 | + parser.add_argument("--repo", required=True, help="OWNER/NAME") |
| 157 | + parser.add_argument("--pr", type=int, required=True) |
| 158 | + parser.add_argument("--publish", action="store_true", help="comment and set the status") |
| 159 | + parser.add_argument("--wait-minutes", type=int, default=0, |
| 160 | + help="poll this long for other checks to finish before judging") |
| 161 | + parser.add_argument("--allowed-secret", action="append", default=[]) |
| 162 | + args = parser.parse_args(argv) |
| 163 | + |
| 164 | + token = os.environ.get("GITHUB_TOKEN") |
| 165 | + allowed = set(args.allowed_secret) |
| 166 | + deadline = time.monotonic() + args.wait_minutes * 60 |
| 167 | + pr, verdict = gather(args.repo, args.pr, token, allowed) |
| 168 | + # Waiting here instead of posting "pending" keeps it to one meaningful update. |
| 169 | + while verdict.state == "pending" and time.monotonic() < deadline: |
| 170 | + time.sleep(60) |
| 171 | + pr, verdict = gather(args.repo, args.pr, token, allowed) |
| 172 | + |
| 173 | + body = render(verdict, pr["head"]["sha"]) |
| 174 | + print(body) |
| 175 | + if args.publish: |
| 176 | + if not token: |
| 177 | + raise SystemExit("--publish needs GITHUB_TOKEN (TechEngineBot's token)") |
| 178 | + publish(args.repo, args.pr, pr["head"]["sha"], body, verdict.state, token) |
| 179 | + # The status is the gate; a red job would only repeat it. |
| 180 | + return 0 |
| 181 | + |
| 182 | + |
| 183 | +if __name__ == "__main__": |
| 184 | + sys.exit(main()) |
0 commit comments