mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +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>
This commit is contained in:
co-authored by
Angelina Lok
Claude Opus 4.7
parent
cfd9a9fba1
commit
670d30ae7e
@@ -0,0 +1,12 @@
|
||||
# Upstream commits this fork has consciously decided never to port.
|
||||
# tools/upstream_triage.py skips anything listed here so it stops re-surfacing
|
||||
# in the weekly Upstream watch report. One SHA per line (short or full); text
|
||||
# after # is a note.
|
||||
#
|
||||
# Only for commits you've reviewed and rejected on purpose. Commits you DO
|
||||
# port drop off automatically once cherry-picked (patch-id match), so they
|
||||
# never need an entry here. Likewise commits that only touch files your fork
|
||||
# removed are auto-skipped - you don't need to list those either.
|
||||
#
|
||||
# This ships empty on the template. Populate it in your own fork, e.g.:
|
||||
# cffacfd # Danish demo portals - my fork removed them on purpose
|
||||
@@ -0,0 +1,85 @@
|
||||
# Weekly upstream triage. Reports only - it NEVER merges, pushes, or edits code.
|
||||
#
|
||||
# It fetches the upstream template, runs tools/upstream_triage.py to sort the
|
||||
# commits this fork lacks into "worth reviewing" vs "probably skip" (dropping
|
||||
# cherry-picks already applied and changes that only touch files this fork
|
||||
# removed), and writes the result into a single rolling issue. You read it and
|
||||
# port anything worth porting by hand.
|
||||
#
|
||||
# The report/act boundary is deliberate and load-bearing: the report stops at
|
||||
# ready-to-run cherry-pick lines and never opens a draft PR or merges. 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. Merges stay a human decision, the same posture
|
||||
# /apply keeps (it drafts, never submits). Keep it that way.
|
||||
#
|
||||
# This is the commit-level companion to tools/check_upstream_updates.py, which
|
||||
# tracks personalized-file version stamps. Two tools, two questions.
|
||||
#
|
||||
# Runs only on forks (guarded below), so the upstream template never triggers
|
||||
# it against itself - GitHub also leaves inherited workflows disabled on a fork
|
||||
# until the owner enables Actions, so the guard is a second fence, not the only
|
||||
# one. Token is the built-in GITHUB_TOKEN, scoped to reading contents and
|
||||
# writing issues in this repo only: the digest can never be written outside the
|
||||
# fork.
|
||||
|
||||
name: Upstream watch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1" # 08:00 UTC every Monday
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage upstream commits
|
||||
# No-op on the upstream template itself. Pinned by
|
||||
# tests/test_upstream_triage.py so a template clone never runs it by surprise.
|
||||
if: github.repository != 'MadsLorentzen/ai-job-search'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Fetch upstream template
|
||||
run: |
|
||||
git remote add upstream https://github.com/MadsLorentzen/ai-job-search.git 2>/dev/null || true
|
||||
git fetch --quiet upstream master
|
||||
|
||||
- name: Build triage report
|
||||
run: |
|
||||
{
|
||||
echo "_Last checked: $(date -u '+%Y-%m-%d %H:%M UTC') · [run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})_"
|
||||
echo
|
||||
python tools/upstream_triage.py --remote upstream --branch master
|
||||
} > report.md
|
||||
cat report.md
|
||||
|
||||
- name: Open or update the rolling issue
|
||||
env:
|
||||
# Built-in token is scoped to this repo only, so the digest can never
|
||||
# be written outside the fork.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Pin to this fork. Without it, the `upstream` git remote added above
|
||||
# makes gh's remote resolution target the base repo, so the digest
|
||||
# would land on upstream's tracker instead of the fork's.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
title="Upstream sync watch"
|
||||
existing=$(gh issue list --state open --search "in:title \"$title\"" \
|
||||
--json number,title --jq ".[] | select(.title==\"$title\") | .number" | head -n1)
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue edit "$existing" --body-file report.md
|
||||
echo "Updated issue #$existing"
|
||||
else
|
||||
gh issue create --title "$title" --body-file report.md
|
||||
echo "Created a new rolling issue"
|
||||
fi
|
||||
@@ -15,6 +15,19 @@ per-file diff commands.
|
||||
|
||||
### Added
|
||||
|
||||
- **Commit-level upstream triage for forks** (#305). A new `tools/upstream_triage.py` walks the
|
||||
commits a fork is behind upstream and sorts them into "worth reviewing" vs "probably skip":
|
||||
cherry-picks already applied drop off on their own (matched by `git patch-id`, so ported work
|
||||
needs no bookkeeping), commits that only touch files the fork removed are set aside, and SHAs in
|
||||
a flat `.github/upstream-wontport.txt` stop resurfacing. It's the commit-history companion to
|
||||
`check_upstream_updates.py`'s version stamps - the two cross-reference each other in their output.
|
||||
Report-only by design: it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or
|
||||
opens a PR, because on a fork "applies cleanly" isn't "correct". A `.github/workflows/upstream-watch.yml`
|
||||
runs it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and
|
||||
scoped to the built-in `GITHUB_TOKEN` so it can never write outside its own fork. SETUP.md 8
|
||||
introduces both tools side by side. Offline tests cover patch-id matching, relevance filtering, the
|
||||
won't-port list, and the workflow guard. Thanks @anjolok1997.
|
||||
|
||||
- **`security_guards.py` now holds `.claude/settings.json` hooks to an allowlist** - the
|
||||
guard read `permissions.allow` and nothing else, so a `hooks` block in the same file
|
||||
passed silently. A hook is strictly more dangerous than a pre-approved permission: a
|
||||
|
||||
@@ -340,7 +340,7 @@ To wipe your profile data and start fresh:
|
||||
|
||||
### Staying up to date
|
||||
|
||||
Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge. Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork).
|
||||
Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge, and `python3 tools/upstream_triage.py` sorts the commits you're behind into "worth reviewing" vs "probably skip" (a weekly workflow can post this to a rolling issue). Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork).
|
||||
|
||||
## Tips for better results
|
||||
|
||||
|
||||
@@ -298,6 +298,16 @@ Upstream keeps improving the methodology files your fork has personalized, so pl
|
||||
python3 tools/check_upstream_updates.py
|
||||
```
|
||||
It compares the `framework_version` markers in your framework files against upstream and lists exactly which methodology files changed, with the diff command for each.
|
||||
|
||||
Two tools answer two different questions, and it's worth running both:
|
||||
- **`check_upstream_updates.py`** — *which of my personalized files changed?* It reads the `framework_version` stamp on each methodology file, so it flags exactly the customized files a release touched.
|
||||
- **`upstream_triage.py`** — *which upstream commits deserve my attention?* It walks the commits you're behind and sorts them into "worth reviewing" vs "probably skip", dropping anything you've already cherry-picked (matched by `git patch-id`, so ported work falls off with no bookkeeping), commits that only touch files your fork removed, and SHAs you've listed in `.github/upstream-wontport.txt`. It's report-only — it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or opens a PR, because on a fork "applies cleanly" isn't "correct".
|
||||
|
||||
```bash
|
||||
python3 tools/upstream_triage.py --remote upstream
|
||||
```
|
||||
|
||||
Forks also inherit a `.github/workflows/upstream-watch.yml` that runs this weekly and writes the result into a single rolling issue (it no-ops on the upstream template itself, and stays disabled on a fork until you enable Actions).
|
||||
3. **Merge normally.** `git merge upstream/master` (or `git pull`) three-way-merges upstream's edits around your personalization; because methodology edits rarely touch the lines `/setup` filled in, most updates land cleanly. A conflict in a personalized file is a *feature*, not a failure — it means upstream changed methodology in a section you customized, and the version marker plus its changelog commit tell you why. Resolve by keeping your data and adopting the methodology change around it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "tools" / "upstream_triage.py"
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "upstream-watch.yml"
|
||||
UPSTREAM_SLUG = "MadsLorentzen/ai-job-search"
|
||||
|
||||
|
||||
def git(root: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=root, check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
class TriageRepoFixture(unittest.TestCase):
|
||||
"""Builds a real git history: a shared base, then an `upstream/master`
|
||||
ref that runs ahead, so the triage script can be exercised fully offline.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
|
||||
(self.root / "tools").mkdir()
|
||||
shutil.copy(SCRIPT, self.root / "tools" / "upstream_triage.py")
|
||||
(self.root / ".github").mkdir()
|
||||
|
||||
git(self.root, "init", "-b", "master")
|
||||
git(self.root, "config", "user.name", "Test")
|
||||
git(self.root, "config", "user.email", "test@example.com")
|
||||
git(self.root, "remote", "add", "upstream",
|
||||
f"https://github.com/{UPSTREAM_SLUG}.git")
|
||||
|
||||
self.write("shared.txt", "base\n")
|
||||
self.write("kept.py", "print('hi')\n")
|
||||
self.commit("init")
|
||||
|
||||
def write(self, rel: str, text: str) -> None:
|
||||
path = self.root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
def commit(self, msg: str) -> str:
|
||||
git(self.root, "add", "-A")
|
||||
git(self.root, "commit", "-m", msg)
|
||||
return git(self.root, "rev-parse", "HEAD").strip()
|
||||
|
||||
def set_upstream_to_head(self) -> None:
|
||||
git(self.root, "update-ref", "refs/remotes/upstream/master", "HEAD")
|
||||
|
||||
def run_triage(self, *args) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(self.root / "tools" / "upstream_triage.py"), *args],
|
||||
cwd=self.root, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
class UpToDateTests(TriageRepoFixture):
|
||||
def test_reports_up_to_date_when_not_behind(self):
|
||||
self.set_upstream_to_head()
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("Up to date", result.stdout)
|
||||
|
||||
|
||||
class RelevanceFilterTests(TriageRepoFixture):
|
||||
def test_commit_touching_only_removed_files_is_skipped(self):
|
||||
# Upstream edits a file this fork never had -> not relevant.
|
||||
self.write("portals/removed_portal.py", "x = 1\n")
|
||||
self.commit("upstream: add removed_portal")
|
||||
self.set_upstream_to_head()
|
||||
# Fork drops back to before that commit and deletes nothing extra;
|
||||
# the file simply is not in fork HEAD.
|
||||
git(self.root, "reset", "--hard", "HEAD~1")
|
||||
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("touches only files not in this fork", result.stdout)
|
||||
self.assertIn("Probably skip", result.stdout)
|
||||
|
||||
def test_commit_touching_kept_files_is_worth_reviewing(self):
|
||||
self.write("kept.py", "print('changed')\n")
|
||||
self.commit("upstream: change kept.py")
|
||||
self.set_upstream_to_head()
|
||||
git(self.root, "reset", "--hard", "HEAD~1")
|
||||
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("Worth reviewing", result.stdout)
|
||||
self.assertIn("kept.py", result.stdout)
|
||||
# Ready-to-run cherry-pick lines are offered, not executed.
|
||||
self.assertIn("git cherry-pick", result.stdout)
|
||||
|
||||
def test_changelog_only_footprint_is_skipped(self):
|
||||
self.write("portals/gone.py", "y = 2\n")
|
||||
self.write("CHANGELOG.md", "- did a thing\n")
|
||||
self.commit("upstream: feature living in removed area + changelog")
|
||||
self.set_upstream_to_head()
|
||||
# Fork ships CHANGELOG.md but not the removed portal file.
|
||||
git(self.root, "reset", "--hard", "HEAD~1")
|
||||
self.write("CHANGELOG.md", "- fork changelog\n")
|
||||
self.commit("fork changelog")
|
||||
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("changelog-only footprint in this fork", result.stdout)
|
||||
|
||||
|
||||
class AlreadyAppliedTests(TriageRepoFixture):
|
||||
def test_cherry_picked_commit_drops_off_via_patch_id(self):
|
||||
# Upstream adds a feature commit, then a second unrelated commit.
|
||||
self.write("kept.py", "print('feature')\n")
|
||||
upstream_sha = self.commit("upstream: add feature")
|
||||
self.write("shared.txt", "upstream edit\n")
|
||||
self.commit("upstream: unrelated change")
|
||||
self.set_upstream_to_head()
|
||||
|
||||
# Fork diverges (its own commit first), then cherry-picks the feature.
|
||||
# The cherry-pick lands with a DIFFERENT sha but the same patch, so
|
||||
# only patch-id matching - not raw sha - can tell it is already ported.
|
||||
git(self.root, "reset", "--hard", "HEAD~2")
|
||||
self.write("fork_only.txt", "mine\n")
|
||||
self.commit("fork: divergent commit")
|
||||
git(self.root, "cherry-pick", upstream_sha)
|
||||
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
# The feature dropped off via patch-id; only the unrelated commit
|
||||
# remains worth reviewing.
|
||||
self.assertIn("already applied (cherry-picked)", result.stdout)
|
||||
self.assertIn("**1** worth reviewing", result.stdout)
|
||||
|
||||
|
||||
class WontPortTests(TriageRepoFixture):
|
||||
def test_listed_sha_is_excluded(self):
|
||||
self.write("kept.py", "print('rejected feature')\n")
|
||||
rejected = self.commit("upstream: feature the fork rejects")
|
||||
self.set_upstream_to_head()
|
||||
git(self.root, "reset", "--hard", "HEAD~1")
|
||||
self.write(".github/upstream-wontport.txt",
|
||||
f"{rejected[:9]} # rejected on purpose\n")
|
||||
self.commit("fork: won't-port list")
|
||||
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("on the fork's won't-port list", result.stdout)
|
||||
|
||||
|
||||
class MissingUpstreamRefTests(TriageRepoFixture):
|
||||
def test_missing_ref_degrades_gracefully(self):
|
||||
# upstream/master ref never materialized.
|
||||
result = self.run_triage()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("was not available", result.stdout)
|
||||
|
||||
|
||||
class WorkflowGuardTests(unittest.TestCase):
|
||||
"""The workflow must no-op on the upstream template, so a template clone
|
||||
never opens an issue by surprise. GitHub Actions can't run offline, so we
|
||||
pin the guard by asserting the job's `if` condition excludes upstream."""
|
||||
|
||||
def test_workflow_is_guarded_against_upstream(self):
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn(f"github.repository != '{UPSTREAM_SLUG}'", text)
|
||||
|
||||
def test_workflow_uses_builtin_token_only(self):
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("GH_TOKEN: ${{ github.token }}", text)
|
||||
# A cross-repo PAT is what let an early run write outside its own repo;
|
||||
# the built-in token can't. Make sure no PAT secret sneaks back in.
|
||||
self.assertNotIn("secrets.", text)
|
||||
|
||||
def test_actions_are_sha_pinned(self):
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- uses:") or stripped.startswith("uses:"):
|
||||
ref = stripped.split("uses:", 1)[1].strip()
|
||||
self.assertIn("@", ref)
|
||||
sha = ref.split("@", 1)[1].split()[0]
|
||||
self.assertRegex(sha, r"^[0-9a-f]{40}$",
|
||||
f"action not SHA-pinned: {ref}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -175,7 +175,6 @@ def main() -> int:
|
||||
print(f" Diff command: git diff {ref} -- {up['path']}")
|
||||
print()
|
||||
print("Review these changes to see if they fit your personalized fork!")
|
||||
return 0
|
||||
else:
|
||||
if errors or missing_upstream:
|
||||
print(
|
||||
@@ -185,6 +184,12 @@ def main() -> int:
|
||||
)
|
||||
else:
|
||||
print(f"[OK] All framework files are up to date with {ref}!")
|
||||
# Version stamps answer "which of my files changed"; commit-level triage
|
||||
# answers "which upstream commits deserve review". Point at the companion.
|
||||
print(
|
||||
f"\nFor commit-level triage of upstream commits, run: "
|
||||
f"python3 tools/upstream_triage.py --remote {remote}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user