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:

    ') + for r in ledoent_fail: + lines.append( + f"
  • {html.escape(r['repo'])}: " + f"{html.escape(r['check_status'])} — " + f"
    {html.escape(r['message'])}
  • " + ) + lines.append("
") + 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