feat(upstream): add commit-level triage and weekly watch for forks (#305) (#320)

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:
Angelina Lok
2026-08-11 19:11:24 +02:00
committed by GitHub
co-authored by Angelina Lok Claude Opus 4.7
parent cfd9a9fba1
commit 670d30ae7e
8 changed files with 547 additions and 3 deletions
+12
View File
@@ -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
+85
View File
@@ -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
+13
View File
@@ -15,6 +15,19 @@ per-file diff commands.
### Added ### 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 - **`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 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 passed silently. A hook is strictly more dangerous than a pre-approved permission: a
+1 -1
View File
@@ -340,7 +340,7 @@ To wipe your profile data and start fresh:
### Staying up to date ### 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 ## Tips for better results
+10
View File
@@ -298,6 +298,16 @@ Upstream keeps improving the methodology files your fork has personalized, so pl
python3 tools/check_upstream_updates.py 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. 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. 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 ## Troubleshooting
+192
View File
@@ -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()
+6 -1
View File
@@ -175,7 +175,6 @@ def main() -> int:
print(f" Diff command: git diff {ref} -- {up['path']}") print(f" Diff command: git diff {ref} -- {up['path']}")
print() print()
print("Review these changes to see if they fit your personalized fork!") print("Review these changes to see if they fit your personalized fork!")
return 0
else: else:
if errors or missing_upstream: if errors or missing_upstream:
print( print(
@@ -185,6 +184,12 @@ def main() -> int:
) )
else: else:
print(f"[OK] All framework files are up to date with {ref}!") 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 return 0
if __name__ == "__main__": if __name__ == "__main__":
+227
View File
@@ -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())