From 7ec1cb164b82355090c1a67e09cfdfb29bbc4486 Mon Sep 17 00:00:00 2001
From: Don Kendall
Date: Mon, 15 Jun 2026 10:01:07 -0400
Subject: [PATCH] feat(digest): rebase opted-in fork feature branches nightly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The org digest synced fork mirror branches (merge-upstream) but left
feature branches to rot once their base advanced. Add an opt-in
`rebase_globs` field in forks.yml: for each match, rebase onto the
freshly-synced upstream_track base and force-push clean ones (PAT push
⇒ the fork's CI re-runs), reporting conflicts in the email + a subject
tag without failing the run. Enabled on ledoent/ddmrp (19.0-mig-*/
19.0-fix-*) so its ~16 not-yet-upstreamed submodule MIGs stay mergeable
on the 1-2/day cadence.
Also carries the in-progress ledoent-overlay rebase check and the
install_forward_port flips that were already staged.
---
.github/forks.yml | 39 ++-
.github/scripts/fork_sync_digest.py | 244 ++++++++++++++++++
.github/scripts/render_digest.py | 95 ++++++-
.../tests/test_rebase_feature_branches.py | 53 ++++
.github/scripts/tests/test_render_digest.py | 82 ++++++
.../scripts/tests/test_sync_branch_state.py | 71 +++++
.github/workflows/fork-sync-and-digest.yml | 19 +-
7 files changed, 581 insertions(+), 22 deletions(-)
create mode 100644 .github/scripts/tests/test_rebase_feature_branches.py
diff --git a/.github/forks.yml b/.github/forks.yml
index 517c077..e5dd37d 100644
--- a/.github/forks.yml
+++ b/.github/forks.yml
@@ -16,6 +16,13 @@
# `.github/workflows/forward-port.yml` to this fork
# so labelled PRs cherry-pick across `branches`.
# Default false; opt-in per fork.
+# rebase_globs: list of fnmatch globs (e.g. ["19.0-mig-*", "19.0-fix-*"]).
+# The digest rebases each matching feature branch onto the
+# freshly-synced `upstream_track` base and force-pushes the
+# clean ones (PAT push ⇒ the fork's CI re-runs); conflicts
+# are reported, never pushed. Absent = skip. Opt-in per
+# fork — only set it where you carry not-yet-merged feature
+# branches that must track an advancing base.
forks:
# === OCA forks — primary 18.0 + 19.0 tracking ===
@@ -23,85 +30,85 @@ forks:
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/sale-workflow
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/calendar
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/mis-builder
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/account-financial-reporting
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/account-financial-tools
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/spreadsheet
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/dms
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/bank-statement-import
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/connector-telephony
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/e-commerce
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/social
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/knowledge
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/geospatial
branches: ["18.0", "19.0", "20.0"]
upstream_org: OCA
upstream_track: "19.0"
- install_forward_port: true
+ install_forward_port: false
- repo: ledoent/OpenUpgrade
# NOTE: the fork's default branch `ledoent` is a CI overlay
@@ -127,6 +134,10 @@ forks:
upstream_org: OCA
upstream_track: "19.0"
install_forward_port: false
+ # ~16 not-yet-upstreamed submodule MIGs + a few fixes ride on top of
+ # OCA 19.0; keep them rebased nightly so each is mergeable when we open
+ # its PR (1-2/day cadence).
+ rebase_globs: ["19.0-mig-*", "19.0-fix-*"]
- repo: ledoent/report-print-send
branches: ["18.0", "19.0", "20.0"]
diff --git a/.github/scripts/fork_sync_digest.py b/.github/scripts/fork_sync_digest.py
index 2ebdb88..12fce86 100644
--- a/.github/scripts/fork_sync_digest.py
+++ b/.github/scripts/fork_sync_digest.py
@@ -21,9 +21,13 @@
from __future__ import annotations
+import fnmatch
import json
+import os
import re
+import subprocess
import sys
+import tempfile
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -195,6 +199,224 @@ def _compare_with_upstream(
return body.get("ahead_by"), body.get("behind_by"), subjects
+def check_ledoent_branch(repo: str, upstream_org: str | None, upstream_track: str | None) -> dict | None:
+ """Test if the ledoent branch of the fork rebases/aggregates cleanly."""
+ status, body = gh("GET", f"/repos/{repo}/branches/ledoent")
+ if status != 200:
+ return None # No ledoent branch exists on this fork, skip
+
+ print(f" Testing ledoent branch for {repo}...", file=sys.stderr)
+ base_branch = upstream_track or "18.0"
+ token = require_token()
+ repo_name = repo.split("/", 1)[1]
+ fork_url = f"https://x-access-token:{token}@github.com/{repo}.git"
+ upstream_org_name = upstream_org or "OCA"
+ upstream_url = f"https://github.com/{upstream_org_name}/{repo_name}.git"
+
+ check_status = "error"
+ error_msg = ""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp_path = Path(tmpdir)
+ repo_dir = tmp_path / "repo"
+
+ try:
+ # 1. Clone the fork's ledoent branch
+ subprocess.run(
+ ["git", "clone", "--branch", "ledoent", "--single-branch", fork_url, "repo"],
+ cwd=tmpdir, check=True, capture_output=True
+ )
+ # 2. Add upstream remote
+ subprocess.run(
+ ["git", "remote", "add", "upstream", upstream_url],
+ cwd=repo_dir, check=True, capture_output=True
+ )
+ # 3. Fetch target branch
+ subprocess.run(
+ ["git", "fetch", "upstream", base_branch],
+ cwd=repo_dir, check=True, capture_output=True
+ )
+ # 4. Configure dummy identity for merging/rebasing
+ subprocess.run(["git", "config", "user.name", "github-actions[bot]"], cwd=repo_dir, check=True)
+ subprocess.run(["git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], cwd=repo_dir, check=True)
+
+ repos_yaml = repo_dir / "repos.yaml"
+ if repos_yaml.exists():
+ # Test gitaggregate
+ res = subprocess.run(
+ ["gitaggregate", "-c", "repos.yaml"],
+ cwd=repo_dir, capture_output=True
+ )
+ if res.returncode != 0:
+ check_status = "conflict"
+ error_msg = f"gitaggregate failed:\n{res.stderr.decode()}"
+ else:
+ check_status = "clean"
+ else:
+ # Test rebase
+ res = subprocess.run(
+ ["git", "rebase", f"upstream/{base_branch}"],
+ cwd=repo_dir, capture_output=True
+ )
+ if res.returncode != 0:
+ subprocess.run(["git", "rebase", "--abort"], cwd=repo_dir, capture_output=True)
+ check_status = "conflict"
+ error_msg = f"git rebase upstream/{base_branch} failed with conflicts:\n{res.stderr.decode()}"
+ else:
+ check_status = "clean"
+ except subprocess.CalledProcessError as e:
+ check_status = "error"
+ error_msg = f"Git command failed: {e.stderr.decode()}"
+ except FileNotFoundError:
+ check_status = "error"
+ error_msg = "gitaggregate executable not found on runner."
+ except Exception as e:
+ check_status = "error"
+ error_msg = f"Unexpected check error: {str(e)}"
+
+ return {
+ "repo": repo,
+ "branch": "ledoent",
+ "check_type": "rebase_check",
+ "status": 200 if check_status == "clean" else (409 if check_status == "conflict" else 500),
+ "check_status": check_status,
+ "message": error_msg or "Clean",
+ "skipped": False,
+ }
+
+
+def _list_matching_branches(repo: str, globs: list[str]) -> list[str]:
+ """Fork branch names matching any fnmatch glob in `globs` (paginated)."""
+ out: list[str] = []
+ page = 1
+ while True:
+ status, body = gh(
+ "GET", f"/repos/{repo}/branches?per_page=100&page={page}"
+ )
+ if status != 200 or not isinstance(body, list) or not body:
+ break
+ names = [b["name"] for b in body]
+ out.extend(
+ n for n in names if any(fnmatch.fnmatch(n, g) for g in globs)
+ )
+ if len(names) < 100:
+ break
+ page += 1
+ return sorted(set(out))
+
+
+def _rebase_one(
+ repo_dir: Path, repo: str, branch: str, base_branch: str, base_ref: str
+) -> dict:
+ """Rebase one fetched feature branch onto base_ref; force-push if clean."""
+
+ def row(status: int, rebase_status: str, behind, message: str) -> dict:
+ return {
+ "repo": repo,
+ "branch": branch,
+ "check_type": "feature_rebase",
+ "status": status,
+ "rebase_status": rebase_status,
+ "behind": behind,
+ "message": message,
+ "skipped": False,
+ }
+
+ def git(*args, **kw):
+ return subprocess.run(
+ ["git", *args], cwd=repo_dir, capture_output=True, **kw
+ )
+
+ fetched = git("fetch", "origin", branch)
+ if fetched.returncode != 0:
+ return row(500, "error", None, f"fetch failed: {fetched.stderr.decode()[:200]}")
+
+ cnt = git("rev-list", "--count", f"origin/{branch}..{base_ref}")
+ behind = int(cnt.stdout.decode().strip() or "0") if cnt.returncode == 0 else None
+ if behind == 0:
+ return row(200, "already-current", 0, "up to date with base")
+
+ git("checkout", "--detach", f"origin/{branch}", check=True)
+ git("clean", "-fdq")
+ mergebase = git("merge-base", base_ref, f"origin/{branch}").stdout.decode().strip()
+ # Content-preserving rebase: hooks off (nothing new to lint — the branch
+ # content is unchanged, only re-parented) and a no-op editor so an empty/
+ # message step can't block in the headless runner.
+ env = {**os.environ, "GIT_EDITOR": "true"}
+ reb = subprocess.run(
+ ["git", "-c", "core.hooksPath=/dev/null", "rebase", "--onto", base_ref, mergebase],
+ cwd=repo_dir, capture_output=True, env=env,
+ )
+ if reb.returncode != 0:
+ files = git("diff", "--name-only", "--diff-filter=U").stdout.decode().split()
+ subprocess.run(
+ ["git", "-c", "core.hooksPath=/dev/null", "rebase", "--abort"],
+ cwd=repo_dir, capture_output=True, env=env,
+ )
+ return row(409, "conflict", behind, "conflict in: " + ", ".join(files[:8]))
+
+ # PAT-authed push ⇒ re-triggers the fork's tests/pre-commit (a GITHUB_TOKEN
+ # push would not). --force-with-lease: the clone fetched origin/
+ # moments ago, so this guards against a racing push in the same window.
+ pushed = git("push", "--force-with-lease", "origin", f"HEAD:refs/heads/{branch}")
+ if pushed.returncode != 0:
+ return row(500, "error", behind, f"push failed: {pushed.stderr.decode()[:200]}")
+ return row(200, "rebased", behind, f"rebased onto {base_branch} (+{behind})")
+
+
+def rebase_feature_branches(repo: str, globs: list[str], base_branch: str) -> list[dict]:
+ """Rebase fork feature branches matching `globs` onto the fork's freshly
+ synced `base_branch`, force-pushing clean rebases and reporting conflicts.
+
+ Runs AFTER the merge-upstream sync in the same fork iteration, so
+ `origin/` already reflects upstream. Whole-fork failures
+ (clone, etc.) collapse to a single error row so the digest still renders.
+ """
+ branches = _list_matching_branches(repo, globs)
+ if not branches:
+ return []
+ print(
+ f" Rebasing {len(branches)} feature branch(es) on {repo} onto {base_branch}...",
+ file=sys.stderr,
+ )
+ token = require_token()
+ fork_url = f"https://x-access-token:{token}@github.com/{repo}.git"
+ results: list[dict] = []
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo_dir = Path(tmpdir) / "repo"
+ clone = subprocess.run(
+ ["git", "clone", "--branch", base_branch, "--single-branch", fork_url, "repo"],
+ cwd=tmpdir, capture_output=True,
+ )
+ if clone.returncode != 0:
+ return [{
+ "repo": repo, "branch": "(clone)", "check_type": "feature_rebase",
+ "status": 500, "rebase_status": "error", "behind": None,
+ "message": f"clone failed: {clone.stderr.decode()[:300]}",
+ "skipped": False,
+ }]
+ subprocess.run(
+ ["git", "config", "user.name", "github-actions[bot]"],
+ cwd=repo_dir, check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "config", "user.email",
+ "41898282+github-actions[bot]@users.noreply.github.com"],
+ cwd=repo_dir, check=True, capture_output=True,
+ )
+ base_ref = f"origin/{base_branch}"
+ for b in branches:
+ results.append(_rebase_one(repo_dir, repo, b, base_branch, base_ref))
+ except Exception as e: # noqa: BLE001 — never let one fork crash the digest
+ results.append({
+ "repo": repo, "branch": "(rebase)", "check_type": "feature_rebase",
+ "status": 500, "rebase_status": "error", "behind": None,
+ "message": f"unexpected error: {e}", "skipped": False,
+ })
+ return results
+
+
def list_recent_mig_prs(
org: str, repo_name: str, track: str, since: datetime
) -> list[dict]:
@@ -226,6 +448,7 @@ def main() -> int:
Path("forks-parsed.json").write_text(json.dumps(forks, indent=2))
sync_results: list[dict] = []
+ rebase_results: list[dict] = []
for f in forks:
for branch in f.get("branches", []):
res = sync_branch(f["repo"], branch, upstream_org=f.get("upstream_org"))
@@ -236,7 +459,28 @@ def main() -> int:
if res.get("diverged"):
tag = f"{tag} DIVERGED+{res['ahead_by']}"
print(f" {res['repo']}@{branch:>10} -> {tag}", file=sys.stderr)
+
+ # Check ledoent branch if it exists on the fork
+ ledoent_res = check_ledoent_branch(f["repo"], f.get("upstream_org"), f.get("upstream_track"))
+ if ledoent_res:
+ sync_results.append(ledoent_res)
+ print(
+ f" {ledoent_res['repo']}@ledoent -> CHECK {ledoent_res['check_status'].upper()}",
+ file=sys.stderr,
+ )
+
+ # Rebase opted-in feature branches onto the freshly-synced base.
+ globs = f.get("rebase_globs")
+ if globs:
+ base = f.get("upstream_track") or "19.0"
+ for r in rebase_feature_branches(f["repo"], globs, base):
+ rebase_results.append(r)
+ print(
+ f" {r['repo']}@{r['branch']} -> REBASE {r['rebase_status'].upper()}",
+ file=sys.stderr,
+ )
Path("sync-results.json").write_text(json.dumps(sync_results, indent=2))
+ Path("rebase-results.json").write_text(json.dumps(rebase_results, indent=2))
since = datetime.now(timezone.utc) - timedelta(hours=24)
mig_buckets: dict[str, list[dict]] = {}
diff --git a/.github/scripts/render_digest.py b/.github/scripts/render_digest.py
index adc7d8c..a84b1a8 100644
--- a/.github/scripts/render_digest.py
+++ b/.github/scripts/render_digest.py
@@ -33,25 +33,32 @@ def render(
sync_results: list[dict],
mig_buckets: dict[str, list[dict]],
distribution: list[dict],
+ rebase_results: list[dict] | None = None,
) -> tuple[str, str, str]:
+ rebase_results = rebase_results or []
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+ # Separate regular syncs from ledoent checks
+ sync_only = [r for r in sync_results if r.get("branch") != "ledoent"]
+ ledoent_checks = [r for r in sync_results if r.get("branch") == "ledoent"]
+
sync_fail = [
- r for r in sync_results if r["status"] >= 400 and not r["skipped"]
+ r for r in sync_only if r["status"] >= 400 and not r["skipped"]
]
sync_updated = [
- r for r in sync_results if r.get("merge_type") in ("fast-forward", "merge")
+ r for r in sync_only if r.get("merge_type") in ("fast-forward", "merge")
]
sync_nochange = [
- r for r in sync_results
+ r for r in sync_only
if r.get("merge_type") == "none" and r["status"] < 400
]
- sync_skipped = [r for r in sync_results if r["skipped"]]
+ sync_skipped = [r for r in sync_only if r["skipped"]]
# Divergence = fork branch has commits upstream doesn't, AND those
# commits aren't part of the managed overlay (forward-port.yml
# chore commit + sync-produced merge commits). Real divergence
# means someone pushed work to a series branch — needs manual fix.
sync_diverged = [
- r for r in sync_results
+ r for r in sync_only
if r.get("diverged") and not r["skipped"]
]
# Managed-overlay branches are 1+ ahead but only by structural
@@ -63,11 +70,27 @@ def render(
if r.get("managed_overlay") and not r["skipped"]
]
+ ledoent_fail = [r for r in ledoent_checks if r["status"] >= 400]
+ ledoent_clean = [r for r in ledoent_checks if r["status"] == 200]
+
+ rebase_done = [r for r in rebase_results if r["rebase_status"] == "rebased"]
+ rebase_current = [r for r in rebase_results if r["rebase_status"] == "already-current"]
+ rebase_conflict = [r for r in rebase_results if r["rebase_status"] == "conflict"]
+ rebase_error = [r for r in rebase_results if r["rebase_status"] == "error"]
+
dist_fail = [r for r in distribution if r["action"] == "failed"]
dist_created = [r for r in distribution if r["action"] == "created"]
dist_updated = [r for r in distribution if r["action"] == "updated"]
dist_unchanged = [r for r in distribution if r["action"] == "unchanged"]
+ ledoent_summary = ""
+ if ledoent_checks:
+ ledoent_summary = (
+ f" · custom branches: {len(ledoent_clean)} clean · "
+ f''
+ f"{len(ledoent_fail)} failed"
+ )
+
lines: list[str] = [
'',
@@ -81,6 +104,7 @@ def render(
f' · {len(sync_diverged)} diverged'
if sync_diverged else ""
)
+ + ledoent_summary
+ "
",
]
if distribution:
@@ -135,6 +159,17 @@ def render(
)
lines.append("")
+ if ledoent_fail:
+ lines.append('⚠️ Custom branch (ledoent) check failures
')
+ lines.append('The following custom branches failed to rebase or aggregate cleanly against their upstream targets:
")
+
if dist_fail:
lines.append('⚠️ Distributor failures
')
for r in dist_fail:
@@ -176,6 +211,40 @@ def render(
else:
lines.append("No upstream [MIG] PRs merged in the last 24h.
")
+ if rebase_results:
+ lines.append(
+ "Feature-branch rebases
"
+ f"{len(rebase_done)} rebased · {len(rebase_current)} already current · "
+ f''
+ f"{len(rebase_conflict)} conflict"
+ + (
+ f' · {len(rebase_error)} error'
+ if rebase_error else ""
+ )
+ + "
"
+ )
+ if rebase_conflict or rebase_error:
+ lines.append(
+ 'Need manual rebase '
+ "(resolve before opening the PR):
"
+ )
+ for r in rebase_conflict + rebase_error:
+ lines.append(
+ f"{html.escape(r['repo'])} "
+ f"({html.escape(r['branch'])}): "
+ f"{html.escape(r['rebase_status'])} — "
+ f"{html.escape(r['message'])} "
+ )
+ lines.append("
")
+ if rebase_done:
+ lines.append("")
+ for r in rebase_done:
+ lines.append(
+ f"{html.escape(r['repo'])} "
+ f"({html.escape(r['branch'])}): {html.escape(r['message'])} "
+ )
+ lines.append("
")
+
if sync_managed_overlay:
lines.append(
'
'
@@ -198,11 +267,20 @@ def render(
subject_parts.append(f"⚠️ {len(dist_fail)} dist fail")
if sync_diverged:
subject_parts.append(f"⚠️ {len(sync_diverged)} diverged")
+ if ledoent_fail:
+ subject_parts.append(f"⚠️ {len(ledoent_fail)} ledoent fail")
+ if rebase_conflict:
+ subject_parts.append(f"⚠️ {len(rebase_conflict)} rebase conflict")
+ if rebase_error:
+ subject_parts.append(f"⚠️ {len(rebase_error)} rebase error")
if total_migs:
subject_parts.append(f"{total_migs} MIG")
subject = " — ".join(subject_parts)
- fail_count = len(sync_fail) + len(dist_fail)
+ # Rebase conflicts/errors are actionable (block the day's PR) but not a
+ # workflow failure — surfaced in the subject, kept out of the exit marker
+ # so the run stays green.
+ fail_count = len(sync_fail) + len(dist_fail) + len(ledoent_fail)
return subject, "\n".join(lines), str(fail_count)
@@ -210,8 +288,11 @@ def main() -> int:
sync_results = _load("sync-results.json", [])
mig_buckets = _load("mig-buckets.json", {})
distribution = _load("forward-port-distribution.json", [])
+ rebase_results = _load("rebase-results.json", [])
- subject, body_html, exit_marker = render(sync_results, mig_buckets, distribution)
+ subject, body_html, exit_marker = render(
+ sync_results, mig_buckets, distribution, rebase_results
+ )
Path("digest.html").write_text(body_html)
Path("digest.subject").write_text(subject)
Path("digest.exit").write_text(exit_marker)
diff --git a/.github/scripts/tests/test_rebase_feature_branches.py b/.github/scripts/tests/test_rebase_feature_branches.py
new file mode 100644
index 0000000..2175e52
--- /dev/null
+++ b/.github/scripts/tests/test_rebase_feature_branches.py
@@ -0,0 +1,53 @@
+"""Cover the feature-branch rebaser's branch-selection + short-circuit.
+
+The actual git rebase/push runs only on a GitHub runner against a live
+fork, so it isn't unit-tested here. What IS pinned: the glob matching
+(a wrong glob silently rebases nothing or the wrong branches) and the
+no-match short-circuit (must NOT clone when nothing matches, or every
+fork without feature branches pays a pointless clone every night).
+"""
+
+import re
+
+import fork_sync_digest as fsd
+
+
+def _paged_gh(pages):
+ """Fake gh() that serves {page_number: [branch-dict, ...]} by ?page=N."""
+ def fake_gh(method, path):
+ # [?&]-anchored so it doesn't match the `per_page=100` earlier in the qs
+ p = int(re.search(r"[?&]page=(\d+)", path).group(1))
+ return (200, pages.get(p, []))
+ return fake_gh
+
+
+def test_list_matching_branches_globs(monkeypatch):
+ monkeypatch.setattr(fsd, "gh", _paged_gh({
+ 1: [
+ {"name": "18.0"}, {"name": "19.0"}, {"name": "ledoent"},
+ {"name": "19.0-mig-ddmrp_adjustment"}, {"name": "19.0-fix-foo"},
+ ],
+ }))
+ out = fsd._list_matching_branches("ledoent/ddmrp", ["19.0-mig-*", "19.0-fix-*"])
+ # sorted, deduped, only the matching feature branches
+ assert out == ["19.0-fix-foo", "19.0-mig-ddmrp_adjustment"]
+
+
+def test_list_matching_branches_pagination(monkeypatch):
+ page1 = [{"name": f"19.0-mig-m{i:03d}"} for i in range(100)] # full page
+ page2 = [{"name": "19.0-mig-zzz"}, {"name": "other"}] # partial → stop
+ monkeypatch.setattr(fsd, "gh", _paged_gh({1: page1, 2: page2}))
+ out = fsd._list_matching_branches("r", ["19.0-mig-*"])
+ assert "19.0-mig-zzz" in out # second page consumed
+ assert "other" not in out # non-match dropped
+ assert len(out) == 101
+
+
+def test_rebase_feature_branches_no_matches_does_not_clone(monkeypatch):
+ monkeypatch.setattr(fsd, "gh", lambda m, p: (200, []))
+
+ def boom(*a, **k): # noqa: ANN002, ANN003
+ raise AssertionError("must not run git when no branches match")
+
+ monkeypatch.setattr(fsd.subprocess, "run", boom)
+ assert fsd.rebase_feature_branches("ledoent/ddmrp", ["19.0-mig-*"], "19.0") == []
diff --git a/.github/scripts/tests/test_render_digest.py b/.github/scripts/tests/test_render_digest.py
index 955a991..bdbceab 100644
--- a/.github/scripts/tests/test_render_digest.py
+++ b/.github/scripts/tests/test_render_digest.py
@@ -187,3 +187,85 @@ def test_html_escaping_in_failure_message():
)
assert "" not in body
assert "<script>" in body
+
+
+def _reb(branch, rebase_status, behind=1, message="", status=None):
+ if status is None:
+ status = {"rebased": 200, "already-current": 200,
+ "conflict": 409, "error": 500}[rebase_status]
+ return {
+ "repo": "ledoent/ddmrp", "branch": branch, "check_type": "feature_rebase",
+ "status": status, "rebase_status": rebase_status, "behind": behind,
+ "message": message, "skipped": False,
+ }
+
+
+def test_rebase_conflict_warns_in_subject_but_is_not_a_failure():
+ # A rebase conflict blocks the day's PR (actionable) but it isn't a
+ # broken-script failure — subject flags it, exit_marker stays 0.
+ subj, body, exit_marker = render_digest.render(
+ sync_results=[_sync(200, "none")],
+ mig_buckets={},
+ distribution=[],
+ rebase_results=[
+ _reb("19.0-mig-ddmrp_adjustment", "rebased", behind=3,
+ message="rebased onto 19.0 (+3)"),
+ _reb("19.0-mig-ddmrp_warning", "conflict", behind=96,
+ message="conflict in: ddmrp_warning/models/foo.py"),
+ ],
+ )
+ assert "⚠️ 1 rebase conflict" in subj
+ assert exit_marker == "0"
+ assert "Feature-branch rebases" in body
+ assert "Need manual rebase" in body
+ assert "ddmrp_warning" in body
+ assert "19.0-mig-ddmrp_adjustment" in body
+
+
+def test_rebase_all_clean_no_subject_warning():
+ subj, body, _ = render_digest.render(
+ sync_results=[_sync(200, "none")],
+ mig_buckets={},
+ distribution=[],
+ rebase_results=[
+ _reb("19.0-mig-a", "rebased", message="rebased onto 19.0 (+1)"),
+ _reb("19.0-mig-b", "already-current", behind=0,
+ message="up to date with base"),
+ ],
+ )
+ assert "rebase conflict" not in subj
+ assert "1 rebased" in body
+ assert "1 already current" in body
+
+
+def test_no_rebase_section_when_empty():
+ _, body, _ = render_digest.render(
+ sync_results=[_sync(200, "none")], mig_buckets={}, distribution=[],
+ rebase_results=[],
+ )
+ assert "Feature-branch rebases" not in body
+
+
+def test_render_ledoent_check_failures():
+ subj, body, exit_marker = render_digest.render(
+ sync_results=[
+ {
+ "repo": "ledoent/OpenUpgrade",
+ "branch": "ledoent",
+ "check_type": "rebase_check",
+ "status": 409,
+ "check_status": "conflict",
+ "message": "gitaggregate failed with conflicts",
+ "skipped": False,
+ }
+ ],
+ mig_buckets={},
+ distribution=[],
+ )
+ assert "⚠️ 1 ledoent fail" in subj
+ assert exit_marker == "1"
+ assert "Custom branch" in body
+ assert "ledoent/OpenUpgrade" in body
+ assert "conflict" in body
+ assert "gitaggregate failed with conflicts" in body
+
diff --git a/.github/scripts/tests/test_sync_branch_state.py b/.github/scripts/tests/test_sync_branch_state.py
index 576cf12..f31ce93 100644
--- a/.github/scripts/tests/test_sync_branch_state.py
+++ b/.github/scripts/tests/test_sync_branch_state.py
@@ -214,3 +214,74 @@ def fake_gh(method, path, body=None):
assert len(calls) == 1
assert r["status"] == 403
assert r["diverged"] is False
+
+
+def test_check_ledoent_branch_non_existent(monkeypatch):
+ # 404 response from GitHub API means no ledoent branch exists
+ monkeypatch.setattr(fork_sync_digest, "gh", lambda method, path: (404, {}))
+ r = fork_sync_digest.check_ledoent_branch("ledoent/x", "OCA", "18.0")
+ assert r is None
+
+
+def test_check_ledoent_branch_rebase_clean(monkeypatch):
+ # ledoent branch exists (200 response)
+ monkeypatch.setattr(fork_sync_digest, "gh", lambda method, path: (200, {"name": "ledoent"}))
+ monkeypatch.setattr(fork_sync_digest, "require_token", lambda: "mock_token")
+
+ # Mock subprocess.run to simulate clean rebase without repos.yaml
+ import subprocess
+ from pathlib import Path
+
+ def fake_run(cmd, *args, **kwargs):
+ class MockCompletedProcess:
+ returncode = 0
+ stdout = b""
+ stderr = b""
+ return MockCompletedProcess()
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ # Mock Path.exists to always return False for repos.yaml
+ original_exists = Path.exists
+ def fake_exists(self):
+ if self.name == "repos.yaml":
+ return False
+ return original_exists(self)
+ monkeypatch.setattr(Path, "exists", fake_exists)
+
+ r = fork_sync_digest.check_ledoent_branch("ledoent/x", "OCA", "18.0")
+ assert r["check_status"] == "clean"
+ assert r["status"] == 200
+ assert r["branch"] == "ledoent"
+
+
+def test_check_ledoent_branch_gitaggregate_fail(monkeypatch):
+ # ledoent branch exists
+ monkeypatch.setattr(fork_sync_digest, "gh", lambda method, path: (200, {"name": "ledoent"}))
+ monkeypatch.setattr(fork_sync_digest, "require_token", lambda: "mock_token")
+
+ import subprocess
+ from pathlib import Path
+
+ def fake_run(cmd, *args, **kwargs):
+ class MockCompletedProcess:
+ # gitaggregate fails
+ returncode = 1 if "gitaggregate" in cmd else 0
+ stdout = b""
+ stderr = b"Conflict in merging branches"
+ return MockCompletedProcess()
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+
+ # Mock Path.exists to return True for repos.yaml
+ original_exists = Path.exists
+ def fake_exists(self):
+ if self.name == "repos.yaml":
+ return True
+ return original_exists(self)
+ monkeypatch.setattr(Path, "exists", fake_exists)
+
+ r = fork_sync_digest.check_ledoent_branch("ledoent/x", "OCA", "18.0")
+ assert r["check_status"] == "conflict"
+ assert r["status"] == 409
+ assert "gitaggregate failed" in r["message"]
+
diff --git a/.github/workflows/fork-sync-and-digest.yml b/.github/workflows/fork-sync-and-digest.yml
index c7bbe31..65ee9da 100644
--- a/.github/workflows/fork-sync-and-digest.yml
+++ b/.github/workflows/fork-sync-and-digest.yml
@@ -53,7 +53,10 @@ concurrency:
jobs:
sync-and-digest:
runs-on: ubuntu-latest
- timeout-minutes: 15
+ # Bumped from 15: the collect step now also clones each fork to check the
+ # ledoent overlay AND clones+rebases opted-in feature branches (forks.yml
+ # rebase_globs), so the git work outweighs the original API-only sync.
+ timeout-minutes: 30
steps:
# Third-party Actions pinned to commit SHAs so a tag retag (or a
@@ -62,6 +65,19 @@ jobs:
# currently maps to; bump both when reviewing for updates.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.10"
+
+ - name: Install git-aggregator
+ run: pip install git-aggregator==4.1
+
+ - name: Configure git identity (required by gitaggregate/rebase checks)
+ run: |
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
# The pipeline is split into collect → distribute → render so the
# forward-port distributor's outcomes land in the email body, not
# just in the artifact tarball where nobody looks.
@@ -128,6 +144,7 @@ jobs:
digest.exit
forks-parsed.json
sync-results.json
+ rebase-results.json
mig-buckets.json
forward-port-distribution.json
retention-days: 7