diff --git a/.github/workflows/pr-overlap.yml b/.github/workflows/pr-overlap.yml new file mode 100644 index 000000000..1e181a5f6 --- /dev/null +++ b/.github/workflows/pr-overlap.yml @@ -0,0 +1,47 @@ +name: PR overlap + +on: + push: + branches: [main] + schedule: + - cron: '15 * * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: pr-overlap + +jobs: + overlap: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + out=$RUNNER_TEMP/overlap + python3 tools/pr_overlap.py --base "${{ github.event.repository.default_branch }}" --out "$out" + marker='' + for file in "$out"/*.md; do + pr=$(basename "$file" .md) + comment=$(gh api "repos/$GH_REPO/issues/$pr/comments" --paginate \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | [.id, .body] | @json" | head -n1) + id=$(jq -r '.[0] // empty' <<< "${comment:-null}") + if [ -s "$file" ]; then + [ "$(jq -r '.[1]' <<< "${comment:-null}")" = "$(cat "$file")" ] && continue + if [ -n "$id" ]; then + gh api -X PATCH "repos/$GH_REPO/issues/comments/$id" -F body=@"$file" > /dev/null + else + gh pr comment "$pr" --body-file "$file" + fi + elif [ -n "$id" ]; then + gh api -X DELETE "repos/$GH_REPO/issues/comments/$id" + fi + done diff --git a/tools/pr_overlap.py b/tools/pr_overlap.py new file mode 100644 index 000000000..26e91bda3 --- /dev/null +++ b/tools/pr_overlap.py @@ -0,0 +1,156 @@ +import argparse +import json +import os +import re +import subprocess +from itertools import combinations +from pathlib import Path + +PARENT = 'if .parent then "\\(.parent.owner.login)/\\(.parent.name)" else .nameWithOwner end' +EXPORT = re.compile(r"^\+.*\bAPS5_VABI\s+(\w+)\s*\(") +DEPENDS = re.compile(r"Depends on:(.*)") +HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))?", re.M) +MARKER = "" + + +def git(*args): + return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout + + +def gh(*args): + return subprocess.run(["gh", *args], capture_output=True, text=True, check=True).stdout + + +def upstream(): + return os.environ.get("GITHUB_REPOSITORY") or gh("repo", "view", "--json", "nameWithOwner,parent", "--jq", PARENT).strip() + + +def open_prs(base): + out = gh("pr", "list", "-R", REPO, "--base", base, "-L", "200", "--json", "number,author,headRefOid,body") + prs = {} + for pr in json.loads(out): + depends = DEPENDS.search(pr["body"] or "") + prs[pr["number"]] = { + "ref": f"refs/pr/{pr['number']}", + "sha": pr["headRefOid"], + "author": pr["author"]["login"], + "depends": {int(n) for n in re.findall(r"#(\d+)", depends.group(1))} if depends else set(), + } + return prs + + +def fetch(base, prs): + refspecs = [f"+refs/heads/{base}:refs/pr/base"] + [f"+refs/pull/{n}/head:{pr['ref']}" for n, pr in prs.items()] + git("fetch", "--no-tags", "-q", f"https://github.com/{REPO}.git", *refspecs) + + +def merge(*args): + result = subprocess.run(["git", "merge-tree", "--write-tree", "--name-only", "--no-messages", *args], + capture_output=True, text=True) + lines = result.stdout.splitlines() + return lines[0], set(lines[1:]) if result.returncode else set() + + +def scan(pr): + tree, conflicted = merge("refs/pr/base", pr["ref"]) + pr["tree"] = None if conflicted else tree + old, new = (git("merge-base", "refs/pr/base", pr["ref"]).strip(), pr["ref"]) if conflicted else ("refs/pr/base", tree) + pr["files"], pr["added"] = set(), set() + for line in git("diff", "--name-status", "--no-renames", old, new).splitlines(): + status, path = line.split("\t", 1) + pr["files"].add(path) + if status == "A": + pr["added"].add(path) + diff = git("diff", "-U0", "--no-color", old, new, "--", "core/libs/prx/*.cpp") + pr["exports"] = {m.group(1) for m in map(EXPORT.match, diff.splitlines()) + if m and not m.group(1).endswith("_nid_no_patch")} + + +def hunks(pr, path): + diff = git("diff", "-U0", "--no-color", "refs/pr/base", pr["tree"], "--", path) + return [(int(start), int(start) + max(int(count or 1), 1) - 1) for start, count in HUNK.findall(diff)] + + +def clashes(a, b, path): + ranges = sorted((min(s1, s2), max(e1, e2)) for s1, e1 in hunks(a, path) for s2, e2 in hunks(b, path) + if s1 <= e2 + 1 and s2 <= e1 + 1) + merged = [] + for start, end in ranges: + if merged and start <= merged[-1][1] + 1: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + return merged + + +def conflicts(a, b): + if not (a["tree"] and b["tree"] and a["files"] & b["files"]): + return {} + files = merge("--merge-base=refs/pr/base", a["tree"], b["tree"])[1] + return {path: [] if path in a["added"] else clashes(a, b, path) for path in files} + + +def overlaps(prs, a, b): + pa, pb = prs[a], prs[b] + if b in pa["depends"] or a in pb["depends"]: + return None + found = {"added": pa["added"] & pb["added"]} + if pa["author"] != pb["author"]: + found["conflicts"] = conflicts(pa, pb) + found["exports"] = pa["exports"] & pb["exports"] + return found if any(found.values()) else None + + +def cell(items): + return "
".join(f"`{item}`" for item in sorted(items or ())) + + +def spans(conflicts, blob): + return "
".join(f"`{path}` " + ", ".join(f"[L{s}-{e}]({blob}/{path}#L{s}-L{e})" if s != e else f"[L{s}]({blob}/{path}#L{s})" + for s, e in ranges) for path, ranges in sorted((conflicts or {}).items())) + + +def report(rows, blob): + if not rows: + return "" + lines = [MARKER, + "Other open pull requests touch the same code. Merging one will break the other, " + "or both implement the same thing. If this one builds on them, list them in **Depends on**.", + "", + "| Pull request | Conflicting files (lines on `main`) | Same exports | Same new files |", + "|---|---|---|---|"] + for number, found in sorted(rows.items()): + lines.append(f'| #{number} | {spans(found.get("conflicts"), blob)} | {cell(found.get("exports"))} | {cell(found["added"])} |') + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="report open pull requests that overlap each other") + parser.add_argument("--base", default="main") + parser.add_argument("--out", type=Path, help="write .md for every open pull request (empty when clean)") + parser.add_argument("--branch", help="check a local branch against the open pull requests instead") + parser.add_argument("--repo", help="owner/name; defaults to the upstream of the current checkout") + args = parser.parse_args() + if bool(args.out) == bool(args.branch): + parser.error("pass exactly one of --out or --branch") + REPO = args.repo or upstream() + prs = open_prs(args.base) + fetch(args.base, prs) + if args.branch: + sha = git("rev-parse", args.branch).strip() + prs = {n: pr for n, pr in prs.items() if pr["sha"] != sha} + prs[0] = {"ref": sha, "author": None, "depends": set()} + for pr in prs.values(): + scan(pr) + rows = {n: {} for n in prs} + pairs = [(0, n) for n in prs if n] if args.branch else combinations(prs, 2) + for a, b in pairs: + if found := overlaps(prs, a, b): + rows[a][b] = rows[b][a] = found + blob = f'https://github.com/{REPO}/blob/{git("rev-parse", "refs/pr/base").strip()}' + if args.branch: + print(report(rows[0], blob) or "No overlap with open pull requests.", end="") + raise SystemExit + args.out.mkdir(parents=True, exist_ok=True) + for number, found in rows.items(): + (args.out / f"{number}.md").write_text(report(found, blob))