mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
c41845e6d7
From the review: git patch-id --stable ignores whitespace, so a conflict resolution that only respaced the change (inside a string, or Python indentation) counted as no change and was carried; --verbatim compares every byte. And a passing run is now taken only from this pull request (by number, or for a fork, where GitHub lists none on the run, by head repository and branch), not from another pull request with the same head commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
128 lines
5.5 KiB
Python
128 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Is this pull request push only a merge of main into a change that already passed CI?
|
|
|
|
python3 -I scripts/ci/main_merge.py --base BASE_SHA --head HEAD_SHA --repo OWNER/NAME \
|
|
--pr NUMBER --head-repo OWNER/NAME --head-ref BRANCH
|
|
|
|
Prints `carried=<url of the passing run>` when all of these hold, else `carried=`:
|
|
- HEAD is a merge commit whose second parent is on main (an ancestor of BASE): "Merge branch
|
|
'main'", or GitHub's Update branch button;
|
|
- the pull request's own change is the same before and after the merge (equal `git patch-id
|
|
--verbatim`, whitespace included), so the merge resolved no conflict and brought nothing into
|
|
the change itself;
|
|
- a ci.yml run of this pull request (by number, or for a fork, where GitHub lists no pull
|
|
request on the run, by head repository and branch) for the change before the merge passed
|
|
with the test shards. When that change was
|
|
itself only a merge of main (a run that carried, its shards skipped), the walk goes on down
|
|
the first parents, through at most MAX_CHAIN merges, to the run where the shards ran.
|
|
|
|
Then the run needs only the repository checks: the change was tested, and main tests the merged
|
|
result after it lands. Any doubt (an error, an API failure, an unexpected shape) prints
|
|
`carried=` and the full checks run. Reads GH_TOKEN from the environment for the API.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
|
|
API = "https://api.github.com"
|
|
SHARD_PREFIX = "Native Linux tests"
|
|
# Consecutive merges of main followed back to a run whose shards ran.
|
|
MAX_CHAIN = 10
|
|
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout
|
|
|
|
|
|
def patch_id(a, b):
|
|
"""The stable patch id of `git diff a b` ("" for an empty diff)."""
|
|
diff = subprocess.run(["git", "diff", "--no-renames", a, b], capture_output=True, check=True).stdout
|
|
if not diff.strip():
|
|
return ""
|
|
# --verbatim: --stable ignores whitespace, which matters in strings and indentation.
|
|
out = subprocess.run(["git", "patch-id", "--verbatim"], input=diff, capture_output=True, check=True).stdout
|
|
return out.split()[0].decode() if out.split() else ""
|
|
|
|
|
|
def merge_of_main(base, head):
|
|
"""The first parent of HEAD when HEAD only merges main into an unchanged change, else None."""
|
|
parents = git("rev-list", "--parents", "-n", "1", head).split()[1:]
|
|
if len(parents) != 2:
|
|
return None
|
|
change, merged = parents
|
|
if subprocess.run(["git", "merge-base", "--is-ancestor", merged, base]).returncode != 0:
|
|
return None
|
|
fork_point = git("merge-base", change, merged).strip()
|
|
before, after = patch_id(fork_point, change), patch_id(merged, head)
|
|
return change if before and before == after else None
|
|
|
|
|
|
def merge_chain(base, head):
|
|
"""The changes HEAD carries from, nearest first: the first parent of each consecutive merge of
|
|
main that left the change's own patch as it was (each one equals the next, so all equal HEAD's)."""
|
|
chain, cur = [], head
|
|
for _ in range(MAX_CHAIN):
|
|
change = merge_of_main(base, cur)
|
|
if change is None:
|
|
break
|
|
chain.append(change)
|
|
cur = change
|
|
return chain
|
|
|
|
|
|
def get(url, token):
|
|
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}"})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
return json.load(r)
|
|
|
|
|
|
def same_pull_request(run, pr, head_repo, head_ref):
|
|
"""Whether a workflow run belongs to this pull request, not another one with the same head."""
|
|
numbers = [p.get("number") for p in run.get("pull_requests") or []]
|
|
if numbers:
|
|
return pr in numbers
|
|
return (run.get("head_repository") or {}).get("full_name") == head_repo and run.get("head_branch") == head_ref
|
|
|
|
|
|
def passing_run(repo, sha, token, pr, head_repo, head_ref):
|
|
"""URL of a successful ci.yml run of this pull request at `sha` whose test shards all passed."""
|
|
runs = get(f"{API}/repos/{repo}/actions/workflows/ci.yml/runs?head_sha={sha}&event=pull_request&status=success&per_page=10", token)
|
|
for run in runs.get("workflow_runs", []):
|
|
if not same_pull_request(run, pr, head_repo, head_ref):
|
|
continue
|
|
jobs = get(f"{API}/repos/{repo}/actions/runs/{run['id']}/jobs?per_page=100", token).get("jobs", [])
|
|
shards = [j for j in jobs if j.get("name", "").startswith(SHARD_PREFIX)]
|
|
if shards and all(j.get("conclusion") == "success" for j in shards):
|
|
return run.get("html_url", "")
|
|
return ""
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base", required=True)
|
|
ap.add_argument("--head", required=True)
|
|
ap.add_argument("--repo", required=True)
|
|
ap.add_argument("--pr", required=True, type=int)
|
|
ap.add_argument("--head-repo", required=True)
|
|
ap.add_argument("--head-ref", required=True)
|
|
a = ap.parse_args()
|
|
carried = ""
|
|
try:
|
|
token = os.environ.get("GH_TOKEN", "")
|
|
for change in merge_chain(a.base, a.head) if token else []:
|
|
carried = passing_run(a.repo, change, token, a.pr, a.head_repo, a.head_ref)
|
|
if carried:
|
|
print(f"{a.head} merges main into {change}, which passed: {carried}", file=sys.stderr)
|
|
break
|
|
except (subprocess.CalledProcessError, OSError, ValueError, KeyError) as e:
|
|
print(f"not carried ({e})", file=sys.stderr)
|
|
carried = ""
|
|
print(f"carried={carried}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|