mirror of
https://github.com/pandoscope/skills.git
synced 2026-09-19 05:58:34 +08:00
593 lines
22 KiB
Python
593 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""The uniform CI gate (agentic-engineering-template#137, #143).
|
|
|
|
Three subcommands, one per job in ci-ok.yml:
|
|
|
|
ticket the PR names its tickets in the canonical ALL-CAPS form
|
|
reviews every human review thread is answered with a verified
|
|
commit URL or a line starting with the central file's
|
|
`no_commit_marker`, or resolved by the reviewer
|
|
aggregate every job of every pull_request-triggered workflow on
|
|
this head SHA succeeded — the one required context
|
|
|
|
Decision logic lives in pure functions over plain data so the tests
|
|
exercise it without a network; `fetch` is the only door to the API.
|
|
Exit 0 on green, 1 with each violation named on red.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
import yaml
|
|
|
|
API = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
|
SERVER = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
|
|
|
|
|
|
def api_url(path):
|
|
"""The absolute URL for an API path, refusing any non-HTTP scheme.
|
|
|
|
`API` comes from the environment, so a hostile or fat-fingered
|
|
`GITHUB_API_URL` could otherwise steer the gate's own reads at
|
|
`file:` and have it judge a PR on whatever it found on disk. The
|
|
scheme is checked here, once, because this is the only place a URL
|
|
is built.
|
|
"""
|
|
url = API + path
|
|
if not url.startswith(("https://", "http://")):
|
|
scheme = url.split(":", 1)[0] if ":" in url else url
|
|
raise ValueError(f"GITHUB_API_URL must be http(s) — refusing scheme {scheme!r}")
|
|
return url
|
|
|
|
|
|
def fetch(path, token):
|
|
"""GET one API path, parsed. The only network call in this file."""
|
|
req = urllib.request.Request( # noqa: S310 — api_url rejects every other scheme
|
|
api_url(path),
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req) as response: # noqa: S310 — checked above
|
|
return json.load(response)
|
|
|
|
|
|
def graphql(query, variables, token):
|
|
"""POST one GraphQL query — the read for what REST does not expose.
|
|
|
|
Review-thread resolution exists only in the GraphQL schema, so this
|
|
is the file's second and last network door: a POST to the fixed
|
|
/graphql path, under the same scheme guard as `fetch`.
|
|
"""
|
|
body = json.dumps({"query": query, "variables": variables}).encode()
|
|
req = urllib.request.Request( # noqa: S310 — api_url rejects every other scheme
|
|
api_url("/graphql"),
|
|
data=body,
|
|
method="POST",
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req) as response: # noqa: S310 — checked above
|
|
payload = json.load(response)
|
|
if payload.get("errors"):
|
|
raise RuntimeError(f"GraphQL errors: {payload['errors']}")
|
|
return payload["data"]
|
|
|
|
|
|
def paginate(path, token, key=None):
|
|
"""Every page of a list endpoint, 100 at a time."""
|
|
sep = "&" if "?" in path else "?"
|
|
items = []
|
|
page = 1
|
|
while True:
|
|
batch = fetch(f"{path}{sep}per_page=100&page={page}", token)
|
|
if key is not None:
|
|
batch = batch.get(key, [])
|
|
items.extend(batch)
|
|
if len(batch) < 100:
|
|
return items
|
|
page += 1
|
|
|
|
|
|
# ------------------------------------------------------------- ticket
|
|
|
|
# `#123` or `owner/repo#123`.
|
|
REF = r"(?:[\w.-]+/[\w.-]+)?#\d+"
|
|
|
|
|
|
def ticket_violations(body, branch, labels, author, config):
|
|
"""Violations of the reference rules, and whether the escape held.
|
|
|
|
The allow-list is exact ALL CAPS: the gate recognizes only the
|
|
canonical forms, and separately REJECTS any of the forge's native
|
|
closing keywords in any other casing — a `resolves #5` in prose
|
|
would close the ticket on merge without the gate ever auditing it,
|
|
which is the false-accept this deny scan exists for. The only
|
|
escape is the `automated` label on a bot-authored PR: an agent
|
|
doing manual work cannot honestly hold either half.
|
|
"""
|
|
allowed = config["allowed"]
|
|
native = {word.lower() for word in config["github_native"]}
|
|
if "automated" in labels and str(author).endswith("[bot]"):
|
|
return [], True
|
|
|
|
problems = []
|
|
text = body or ""
|
|
found = re.findall(
|
|
r"\b(" + "|".join(map(re.escape, allowed)) + r") (" + REF + r")", text
|
|
)
|
|
for match in re.finditer(r"\b([A-Za-z]+) (" + REF + r")", text):
|
|
word, ref = match.group(1), match.group(2)
|
|
if word in allowed:
|
|
continue
|
|
if word.lower() in native or word.upper() in allowed:
|
|
problems.append(
|
|
f"'{word} {ref}' is not canonical — write "
|
|
f"{'/'.join(sorted(allowed))} in ALL CAPS, or drop the keyword"
|
|
)
|
|
if not found:
|
|
problems.append(
|
|
"the body carries no canonical ticket reference — "
|
|
f"{'/'.join(sorted(allowed))} #n (or owner/repo#n) is required"
|
|
)
|
|
|
|
named = re.match(config["branch_pattern"], branch or "")
|
|
if named:
|
|
in_body = {re.search(r"#(\d+)$", ref).group(1) for _, ref in found}
|
|
for number in sorted(set(named.group(1).split("-")) - in_body, key=int):
|
|
problems.append(
|
|
f"the branch names ticket {number} but the body never "
|
|
f"references #{number} with a canonical keyword"
|
|
)
|
|
return problems, False
|
|
|
|
|
|
def closing_refs(body, config, repo):
|
|
"""The tickets this PR would close on merge, as owner/repo#n.
|
|
|
|
Only keywords the central file tags `closing` count — ADVANCES is
|
|
non-closing by design, and hardcoding the split here would be the
|
|
second copy of a fact reference-keywords.json already owns. Bare
|
|
`#n` is the merging repo's own ticket, so it normalizes against
|
|
`repo` rather than staying ambiguous.
|
|
"""
|
|
closers = sorted(w for w, kind in config["allowed"].items() if kind == "closing")
|
|
refs = set()
|
|
pattern = r"\b(?:" + "|".join(map(re.escape, closers)) + r") (" + REF + r")"
|
|
for match in re.finditer(pattern, body or ""):
|
|
ref = match.group(1)
|
|
refs.add(ref if "/" in ref else f"{repo}{ref}")
|
|
return sorted(refs)
|
|
|
|
|
|
def open_prs_of(events, exclude):
|
|
"""Open PRs among a ticket's cross-references, as owner/repo#n.
|
|
|
|
The timeline is the forge's own reference index: no text search to
|
|
miss a phrasing, cross-repo for free, and sources the token cannot
|
|
read are simply absent rather than errors. `exclude` drops the PR
|
|
under judgement — it always references its own tickets.
|
|
"""
|
|
prs = set()
|
|
for event in events:
|
|
if event.get("event") != "cross-referenced":
|
|
continue
|
|
source = event.get("source", {}).get("issue") or {}
|
|
if "pull_request" not in source or source.get("state") != "open":
|
|
continue
|
|
name = f"{source['repository']['full_name']}#{source['number']}"
|
|
if name != exclude:
|
|
prs.add(name)
|
|
return sorted(prs)
|
|
|
|
|
|
def warn_premature_close(body, config, repo, number, token):
|
|
"""#150: closing a ticket other open PRs still reference is loud.
|
|
|
|
A warning, never a failure — a shared mention is often legitimate
|
|
(a see-also, a quoted changelog), and forcing ADVANCES onto a
|
|
genuinely final PR would teach people to stop declaring closes.
|
|
Anything unreadable is a notice for the same reason a consumer
|
|
whose workflow lags the script by one release must not go red.
|
|
"""
|
|
tickets = closing_refs(body, config, repo)
|
|
if not tickets:
|
|
return
|
|
if not token:
|
|
print("::notice::no GH_TOKEN — cannot check closing targets for other open PRs")
|
|
return
|
|
for ticket in tickets:
|
|
ticket_repo, _, ticket_number = ticket.rpartition("#")
|
|
try:
|
|
events = paginate(
|
|
f"/repos/{ticket_repo}/issues/{ticket_number}/timeline", token
|
|
)
|
|
except (OSError, ValueError) as error:
|
|
print(f"::notice::could not check {ticket} for other open PRs: {error}")
|
|
continue
|
|
others = open_prs_of(events, exclude=f"{repo}#{number}")
|
|
if others:
|
|
print(
|
|
f"::warning::merging closes {ticket}, but other open PRs still "
|
|
f"reference it: {', '.join(others)}. If they share its work, "
|
|
f"this PR should ADVANCES the ticket and the last one close it."
|
|
)
|
|
|
|
|
|
def live_pr(payload, token):
|
|
"""The PR as it is NOW, falling back to the event's snapshot.
|
|
|
|
The payload is frozen when the run is created, and a re-run replays
|
|
that same payload — so a body corrected after a red run stays
|
|
invisible to every re-run of it, and the gate fails forever for a
|
|
reason that no longer exists (#159). Only the number is taken from
|
|
the event; everything judged is read live.
|
|
|
|
A read that fails leaves the payload in place: the gate judges a
|
|
possibly-stale body rather than erroring, which is the same verdict
|
|
it gave before this existed, and never a pass it did not earn.
|
|
"""
|
|
if not token:
|
|
return payload
|
|
try:
|
|
fresh = fetch(
|
|
f"/repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{payload['number']}", token
|
|
)
|
|
except OSError as err:
|
|
print(
|
|
f"::notice::could not read the live pull request ({err}) — judging the event payload"
|
|
)
|
|
return payload
|
|
return fresh
|
|
|
|
|
|
def run_ticket():
|
|
with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as handle:
|
|
event = json.load(handle)
|
|
pr = live_pr(event["pull_request"], os.environ.get("GH_TOKEN"))
|
|
config = reference_config()
|
|
problems, escaped = ticket_violations(
|
|
pr.get("body"),
|
|
pr["head"]["ref"],
|
|
[label["name"] for label in pr.get("labels", [])],
|
|
pr["user"]["login"],
|
|
config,
|
|
)
|
|
if escaped:
|
|
print("automated escape: bot-authored PR carries the automated label.")
|
|
return 0
|
|
for problem in problems:
|
|
print(f"::error::{problem}")
|
|
if not problems:
|
|
print("Ticket references are canonical.")
|
|
warn_premature_close(
|
|
pr.get("body"),
|
|
config,
|
|
os.environ["GITHUB_REPOSITORY"],
|
|
pr["number"],
|
|
os.environ.get("GH_TOKEN"),
|
|
)
|
|
return 1 if problems else 0
|
|
|
|
|
|
# ------------------------------------------------------------ reviews
|
|
|
|
|
|
def thread_of(comments):
|
|
"""REST review comments grouped into threads by their root."""
|
|
threads = {}
|
|
for comment in comments:
|
|
root = comment.get("in_reply_to_id") or comment["id"]
|
|
threads.setdefault(root, []).append(comment)
|
|
return list(threads.values())
|
|
|
|
|
|
def review_violations(
|
|
threads, pr_author, pr_shas, base_url, marker, resolved=frozenset()
|
|
):
|
|
"""The agent's worklist: human threads still needing a response.
|
|
|
|
Each violation names its exact thread — opener, path, a quote of
|
|
the comment and its URL — because the reader is the agent deciding
|
|
what to do next, not a human who already knows which comment they
|
|
left.
|
|
|
|
Answered: a full commit URL under `base_url` whose sha is one of
|
|
this PR's own commits — a pasted-but-wrong link fails, and a bare
|
|
hash fails by design — or a line starting exactly with the central
|
|
file's `no_commit_marker`. A thread the reviewer RESOLVED is off
|
|
the worklist regardless: resolution is the reviewer's own sign-off
|
|
that nothing more is needed, and demanding a reply on top of it
|
|
gated pandoscope/skills#30 on wording nobody was waiting for
|
|
(agentic-engineering-template#166).
|
|
"""
|
|
url_re = re.compile(
|
|
re.escape(base_url) + r"/[\w.-]+/[\w.-]+/commit/([0-9a-f]{7,40})\b"
|
|
)
|
|
problems = []
|
|
for comments in threads:
|
|
first = comments[0]
|
|
opener = first["user"]["login"]
|
|
if opener == pr_author or opener.endswith("[bot]"):
|
|
continue
|
|
if first.get("id") in resolved:
|
|
continue
|
|
answered = False
|
|
for reply in comments[1:]:
|
|
if reply["user"]["login"] != pr_author:
|
|
continue
|
|
body = reply.get("body") or ""
|
|
linked = any(
|
|
any(sha.startswith(match.group(1)) for sha in pr_shas)
|
|
for match in url_re.finditer(body)
|
|
)
|
|
marked = any(line.strip().startswith(marker) for line in body.splitlines())
|
|
if linked or marked:
|
|
answered = True
|
|
break
|
|
if not answered:
|
|
where = first.get("path") or "(general)"
|
|
quote = " ".join((first.get("body") or "").split())
|
|
if len(quote) > 90:
|
|
quote = quote[:87] + "..."
|
|
link = first.get("html_url") or ""
|
|
problems.append(
|
|
f'unanswered review thread by {opener} at {where}: "{quote}"'
|
|
+ (f" ({link})" if link else "")
|
|
+ f" — reply with a commit URL on this PR or a '{marker} <why>'"
|
|
" line, or the reviewer resolves the thread"
|
|
)
|
|
return problems
|
|
|
|
|
|
RESOLVED_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 comments(first: 1) { nodes { databaseId } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
def resolved_roots(nodes):
|
|
"""Root-comment ids of resolved threads, from reviewThreads nodes.
|
|
|
|
The databaseId is the same id the REST comments endpoint returns,
|
|
which is what lets resolution — GraphQL-only — exempt threads built
|
|
from REST data.
|
|
"""
|
|
roots = set()
|
|
for node in nodes:
|
|
if not node.get("isResolved"):
|
|
continue
|
|
for comment in (node.get("comments") or {}).get("nodes") or []:
|
|
if comment.get("databaseId") is not None:
|
|
roots.add(comment["databaseId"])
|
|
return roots
|
|
|
|
|
|
def resolved_thread_roots(repo, number, token):
|
|
"""Every resolved thread's root id, paged."""
|
|
owner, name = repo.split("/", 1)
|
|
nodes = []
|
|
cursor = None
|
|
while True:
|
|
data = graphql(
|
|
RESOLVED_QUERY,
|
|
{"owner": owner, "name": name, "number": number, "cursor": cursor},
|
|
token,
|
|
)
|
|
threads = data["repository"]["pullRequest"]["reviewThreads"]
|
|
nodes.extend(threads["nodes"])
|
|
if not threads["pageInfo"]["hasNextPage"]:
|
|
return resolved_roots(nodes)
|
|
cursor = threads["pageInfo"]["endCursor"]
|
|
|
|
|
|
def reference_config():
|
|
"""The central keyword file — required, never defaulted (#144).
|
|
|
|
Every rule value is passed explicitly from the one central
|
|
location; a missing file or key fails loudly rather than silently
|
|
reverting to a built-in the file no longer controls. The store
|
|
variants carry the same file for the same reason, byte-pinned.
|
|
"""
|
|
with open(os.environ["REFERENCE_KEYWORDS"], encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def run_reviews():
|
|
token = os.environ["GH_TOKEN"]
|
|
repo = os.environ["GITHUB_REPOSITORY"]
|
|
with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as handle:
|
|
event = json.load(handle)
|
|
pr = event["pull_request"]
|
|
number = pr["number"]
|
|
comments = paginate(f"/repos/{repo}/pulls/{number}/comments", token)
|
|
shas = [c["sha"] for c in paginate(f"/repos/{repo}/pulls/{number}/commits", token)]
|
|
marker = reference_config()["no_commit_marker"]
|
|
problems = review_violations(
|
|
thread_of(comments),
|
|
pr["user"]["login"],
|
|
shas,
|
|
SERVER,
|
|
marker,
|
|
resolved=resolved_thread_roots(repo, number, token),
|
|
)
|
|
for problem in problems:
|
|
print(f"::error::{problem}")
|
|
if not problems:
|
|
print("Every human review thread is answered.")
|
|
return 1 if problems else 0
|
|
|
|
|
|
# ---------------------------------------------------------- aggregate
|
|
|
|
|
|
def triggers_of(doc):
|
|
"""The `on:` mapping of a parsed workflow. YAML 1.1 reads bare
|
|
`on` as boolean True, so both spellings are looked up."""
|
|
if not isinstance(doc, dict):
|
|
return {}
|
|
on = doc.get("on", doc.get(True, {}))
|
|
if isinstance(on, str):
|
|
return {on: None}
|
|
if isinstance(on, list):
|
|
return {name: None for name in on}
|
|
return on if isinstance(on, dict) else {}
|
|
|
|
|
|
def expects_pr_run(text):
|
|
"""Does this workflow produce a run for every PR head SHA?
|
|
|
|
True when it triggers on pull_request with default types or an
|
|
explicit list containing `synchronize` — anything narrower (say
|
|
`[opened]`) has no run for later pushes, so no run can be awaited.
|
|
"""
|
|
triggers = triggers_of(yaml.safe_load(text))
|
|
if "pull_request" not in triggers:
|
|
return False
|
|
spec = triggers["pull_request"] or {}
|
|
types = spec.get("types") if isinstance(spec, dict) else None
|
|
return types is None or "synchronize" in types
|
|
|
|
|
|
def aggregate_verdict(expected, runs, jobs_of):
|
|
"""(pending, failures) over the expected workflows' newest runs.
|
|
|
|
A workflow with no run yet is pending, not failed — the poll loop
|
|
waits it out. A completed job with any conclusion but success is a
|
|
failure: a job that was cancelled or skipped did not pass, and a
|
|
gate that shrugged there would read absence as health.
|
|
"""
|
|
pending, failures = [], []
|
|
for path in expected:
|
|
run = runs.get(path)
|
|
if run is None:
|
|
pending.append(f"{path}: no run for this head SHA yet")
|
|
continue
|
|
if run["status"] != "completed":
|
|
pending.append(f"{path}: run {run['id']} is {run['status']}")
|
|
continue
|
|
for job in jobs_of(run["id"]):
|
|
if job["conclusion"] != "success":
|
|
failures.append(
|
|
f"{path}: job '{job['name']}' concluded {job['conclusion']}"
|
|
)
|
|
return pending, failures
|
|
|
|
|
|
def own_verdict(jobs):
|
|
"""(pending, failures) over the gate's own run — judged job by job,
|
|
because this run cannot complete while the gate is still inside it.
|
|
The `ci-ok` job itself is the one exclusion, or it would wait for
|
|
itself forever; its incomplete siblings are pending, not failed,
|
|
since they are in flight exactly while this loop runs.
|
|
"""
|
|
pending, failures = [], []
|
|
for job in jobs:
|
|
if job["name"] == "ci-ok":
|
|
continue
|
|
if job["status"] != "completed":
|
|
pending.append(f"own run: job '{job['name']}' is {job['status']}")
|
|
elif job["conclusion"] != "success":
|
|
failures.append(
|
|
f"own run: job '{job['name']}' concluded {job['conclusion']}"
|
|
)
|
|
return pending, failures
|
|
|
|
|
|
def own_workflow_path(workflow_ref, repo):
|
|
"""The gate's own workflow file, from GITHUB_WORKFLOW_REF.
|
|
|
|
Static, never inferred from the run listing: the gate re-runs on
|
|
review events, and those runs are invisible to a listing filtered
|
|
to `event=pull_request` — the lookup then misses itself and judges
|
|
its own workflow by a sibling run the concurrency group already
|
|
cancelled, failing the head that is in fact green.
|
|
"""
|
|
path = workflow_ref.split("@", 1)[0]
|
|
prefix = f"{repo}/"
|
|
return path[len(prefix) :] if path.startswith(prefix) else path
|
|
|
|
|
|
def run_aggregate():
|
|
token = os.environ["GH_TOKEN"]
|
|
repo = os.environ["GITHUB_REPOSITORY"]
|
|
sha = os.environ["HEAD_SHA"]
|
|
own_run = int(os.environ["GITHUB_RUN_ID"])
|
|
own_path = own_workflow_path(os.environ["GITHUB_WORKFLOW_REF"], repo)
|
|
deadline = time.monotonic() + int(os.environ.get("CI_OK_TIMEOUT", "1500"))
|
|
|
|
workflows = {}
|
|
root = ".github/workflows"
|
|
for name in sorted(os.listdir(root)):
|
|
if name.endswith((".yml", ".yaml")):
|
|
with open(os.path.join(root, name), encoding="utf-8") as handle:
|
|
workflows[f"{root}/{name}"] = handle.read()
|
|
expected = [path for path, text in workflows.items() if expects_pr_run(text)]
|
|
print("Aggregating:", ", ".join(expected))
|
|
|
|
while True:
|
|
listed = paginate(
|
|
f"/repos/{repo}/actions/runs?head_sha={sha}&event=pull_request",
|
|
token,
|
|
"workflow_runs",
|
|
)
|
|
# Newest run per workflow path. The gate's own workflow is
|
|
# judged separately, job by job, since its run cannot complete
|
|
# while this loop is inside it.
|
|
runs = {}
|
|
for run in sorted(listed, key=lambda r: r["id"]):
|
|
runs[run["path"]] = run
|
|
|
|
def jobs_of(run_id):
|
|
return paginate(f"/repos/{repo}/actions/runs/{run_id}/jobs", token, "jobs")
|
|
|
|
pending, failures = aggregate_verdict(
|
|
[p for p in expected if p != own_path], runs, jobs_of
|
|
)
|
|
own_pending, own_failures = own_verdict(jobs_of(own_run))
|
|
pending.extend(own_pending)
|
|
failures.extend(own_failures)
|
|
if failures:
|
|
for failure in failures:
|
|
print(f"::error::{failure}")
|
|
return 1
|
|
if not pending:
|
|
print("Every job of every PR workflow succeeded.")
|
|
return 0
|
|
if time.monotonic() > deadline:
|
|
for item in pending:
|
|
print(f"::error::still pending at the gate's timeout — {item}")
|
|
return 1
|
|
print("waiting:", "; ".join(pending))
|
|
time.sleep(15)
|
|
|
|
|
|
def main():
|
|
verb = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
runners = {"ticket": run_ticket, "reviews": run_reviews, "aggregate": run_aggregate}
|
|
if verb not in runners:
|
|
print(f"usage: check_gate.py {{{'|'.join(runners)}}}", file=sys.stderr)
|
|
return 2
|
|
return runners[verb]()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|