mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
Forks tracking this template face a weekly "which of these commits do I actually care about?" question. check_upstream_updates.py answers it at the file level (version stamps); this adds the commit-level half. tools/upstream_triage.py walks the commits a fork is behind and splits them into "worth reviewing" and "probably skip". Work already ported drops off on its own via git patch-id, commits touching only files the fork removed are set aside, and SHAs in .github/upstream-wontport.txt stay hidden. It reports and nothing more - ready-to-run cherry-pick lines, but no merge, push, or PR, since on a fork "applies cleanly" is not "correct". .github/workflows/upstream-watch.yml runs it weekly into one rolling issue. It no-ops on the upstream template (guarded, and pinned by a test) and uses only the built-in GITHUB_TOKEN, so it can never write outside its own fork. The two tools point at each other in their output; README, SETUP 8, and CHANGELOG introduce them together. Tests cover patch-id matching, relevance filtering, the won't-port list, and the workflow guard - all offline. Co-authored-by: Angelina Lok <angelina@chattermill.io> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
228 lines
8.2 KiB
Python
Executable File
228 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Triage upstream commits this fork has not picked up yet.
|
|
|
|
Emits a Markdown report that sorts the behind-list into "worth reviewing" vs
|
|
"probably skip", so a human decides what to merge/port. It never merges,
|
|
pushes, or edits anything - it only reads git history and prints. This is the
|
|
deliberate report/act boundary: on a fork "applies cleanly" is not "correct" -
|
|
a commit for portals the fork dropped can cherry-pick fine and still be wrong,
|
|
and that silent-wrong case is worse than a conflict. So the report stops at
|
|
ready-to-run cherry-pick lines; a human runs them.
|
|
|
|
This is the commit-level companion to check_upstream_updates.py. That tool
|
|
answers "which of my personalized framework files changed" (version stamps);
|
|
this one answers "which upstream commits deserve my attention" (commit history).
|
|
Two tools, two questions - each cross-references the other in its output.
|
|
|
|
Two signals drive the sort:
|
|
|
|
1. Already applied? A cherry-pick lands with a NEW sha but the same patch, so a
|
|
raw sha comparison misreports it as missing. We compute git patch-ids for the
|
|
fork-only commits and treat any upstream commit whose patch-id (or exact
|
|
subject) matches as already applied.
|
|
|
|
2. Relevant to this fork? A commit that only touches files this fork deleted
|
|
(e.g. removed demo portals) is almost certainly N/A. We check each commit's
|
|
touched paths against the working tree and flag accordingly.
|
|
|
|
Usage: python tools/upstream_triage.py [--remote upstream] [--branch master]
|
|
Exits 0 always (a report, not a gate). Prints a note to stderr and exits 0 if
|
|
the upstream ref is unavailable, so a scheduled job degrades gracefully.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def git(*args: str) -> str:
|
|
return subprocess.run(
|
|
["git", *args], capture_output=True, text=True, check=True
|
|
).stdout
|
|
|
|
|
|
def rev_list(range_spec: str) -> list[str]:
|
|
out = git("rev-list", "--no-merges", range_spec).strip()
|
|
return out.splitlines() if out else []
|
|
|
|
|
|
def patch_id(sha: str) -> str | None:
|
|
"""Stable patch-id for a commit, or None if it has no diff."""
|
|
show = subprocess.run(
|
|
["git", "show", sha], capture_output=True, text=True, check=True
|
|
).stdout
|
|
r = subprocess.run(
|
|
["git", "patch-id", "--stable"], input=show, capture_output=True, text=True
|
|
)
|
|
line = r.stdout.strip()
|
|
return line.split()[0] if line else None
|
|
|
|
|
|
def subject(sha: str) -> str:
|
|
return git("show", "-s", "--format=%s", sha).strip()
|
|
|
|
|
|
def files_touched(sha: str) -> list[str]:
|
|
out = git("show", "--name-only", "--format=", sha).strip()
|
|
return [f for f in out.splitlines() if f]
|
|
|
|
|
|
def path_exists(path: str) -> bool:
|
|
# ls-tree against HEAD is authoritative for "does this fork still ship it".
|
|
r = subprocess.run(
|
|
["git", "cat-file", "-e", f"HEAD:{path}"], capture_output=True
|
|
)
|
|
return r.returncode == 0
|
|
|
|
|
|
def remote_slug(remote: str) -> str | None:
|
|
"""owner/repo for a GitHub remote, or None if it can't be parsed."""
|
|
try:
|
|
url = git("remote", "get-url", remote).strip()
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
for sep in ("github.com/", "github.com:"):
|
|
if sep in url:
|
|
path = url.split(sep, 1)[1]
|
|
return path[:-4] if path.endswith(".git") else path
|
|
return None
|
|
|
|
|
|
def load_wontport(path: str) -> list[str]:
|
|
"""SHA prefixes the fork has decided never to port; missing file -> []."""
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
raw = f.read()
|
|
except FileNotFoundError:
|
|
return []
|
|
entries = []
|
|
for line in raw.splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if line:
|
|
entries.append(line)
|
|
return entries
|
|
|
|
|
|
def commit_cell(short: str, sha: str, slug: str | None) -> str:
|
|
if slug:
|
|
return f"[`{short}`](https://github.com/{slug}/commit/{sha})"
|
|
return f"`{short}`"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--remote", default="upstream")
|
|
ap.add_argument("--branch", default="master")
|
|
ap.add_argument("--wontport", default=".github/upstream-wontport.txt")
|
|
args = ap.parse_args()
|
|
ref = f"{args.remote}/{args.branch}"
|
|
slug = remote_slug(args.remote)
|
|
wontport = load_wontport(args.wontport)
|
|
|
|
try:
|
|
git("rev-parse", "--verify", ref)
|
|
except subprocess.CalledProcessError:
|
|
print(
|
|
f"note: {ref} not available (add the remote and fetch it first); "
|
|
"nothing to triage.",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"_Upstream ref `{ref}` was not available when this ran._")
|
|
return 0
|
|
|
|
behind = rev_list(f"HEAD..{ref}")
|
|
if not behind:
|
|
print(f"Up to date with `{ref}`. Nothing to review. :white_check_mark:")
|
|
_print_crossref(ref)
|
|
return 0
|
|
|
|
fork_only = rev_list(f"{ref}..HEAD")
|
|
fork_patch_ids = {p for p in (patch_id(s) for s in fork_only) if p}
|
|
fork_subjects = {subject(s) for s in fork_only}
|
|
|
|
review: list[tuple[str, str, str, list[str]]] = []
|
|
skip: list[tuple[str, str, str, str]] = []
|
|
|
|
for sha in behind:
|
|
subj = subject(sha)
|
|
short = sha[:9]
|
|
if patch_id(sha) in fork_patch_ids or subj in fork_subjects:
|
|
skip.append((short, sha, subj, "already applied (cherry-picked)"))
|
|
continue
|
|
if any(sha.startswith(e) for e in wontport):
|
|
skip.append((short, sha, subj, "on the fork's won't-port list"))
|
|
continue
|
|
touched = files_touched(sha)
|
|
present = [f for f in touched if path_exists(f)]
|
|
# A commit whose only surviving footprint is the changelog is one whose
|
|
# real change lives in files this fork removed - the code doesn't apply,
|
|
# only a doc line would. Low signal; demote it.
|
|
substantive = [f for f in present if f != "CHANGELOG.md"]
|
|
if touched and not present:
|
|
skip.append((short, sha, subj, "touches only files not in this fork"))
|
|
elif present and not substantive:
|
|
skip.append((short, sha, subj, "changelog-only footprint in this fork"))
|
|
else:
|
|
review.append((short, sha, subj, substantive))
|
|
|
|
lines: list[str] = []
|
|
lines.append(f"Upstream `{ref}` has **{len(behind)}** commit(s) this fork lacks: "
|
|
f"**{len(review)}** worth reviewing, **{len(skip)}** probably skippable.")
|
|
lines.append("")
|
|
lines.append("_This is a triage report. Nothing was merged - review and port by hand._")
|
|
lines.append("")
|
|
|
|
lines.append("### Worth reviewing")
|
|
if review:
|
|
lines.append("")
|
|
lines.append("| Commit | Subject | Fork files it touches |")
|
|
lines.append("|---|---|---|")
|
|
for short, sha, subj, present in review:
|
|
shown = ", ".join(f"`{p}`" for p in present[:4]) or "_(new/shared paths)_"
|
|
if len(present) > 4:
|
|
shown += f" +{len(present) - 4} more"
|
|
lines.append(f"| {commit_cell(short, sha, slug)} | {subj} | {shown} |")
|
|
# Ready-to-run cherry-pick lines - still information, not action. The
|
|
# report stops here on purpose; a human runs (and verifies) these.
|
|
lines.append("")
|
|
lines.append("<details><summary>Ready-to-run cherry-picks (review each before running)</summary>")
|
|
lines.append("")
|
|
lines.append("```bash")
|
|
for short, sha, subj, _ in review:
|
|
lines.append(f"git cherry-pick {sha} # {subj}")
|
|
lines.append("```")
|
|
lines.append("")
|
|
lines.append("</details>")
|
|
else:
|
|
lines.append("")
|
|
lines.append("_None._")
|
|
lines.append("")
|
|
|
|
lines.append("### Probably skip")
|
|
if skip:
|
|
lines.append("")
|
|
lines.append("| Commit | Subject | Why |")
|
|
lines.append("|---|---|---|")
|
|
for short, sha, subj, why in skip:
|
|
lines.append(f"| {commit_cell(short, sha, slug)} | {subj} | {why} |")
|
|
else:
|
|
lines.append("")
|
|
lines.append("_None._")
|
|
|
|
print("\n".join(lines))
|
|
_print_crossref(ref)
|
|
return 0
|
|
|
|
|
|
def _print_crossref(ref: str) -> None:
|
|
print()
|
|
print(
|
|
"_For personalized-file version stamps (which methodology files changed), "
|
|
f"run `python tools/check_upstream_updates.py --remote {ref.split('/')[0]}`._"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|