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/
ledoent) check failuresThe following custom branches failed to rebase or aggregate cleanly against their upstream targets:
{html.escape(r['repo'])}: "
+ f"{html.escape(r['check_status'])} — "
+ f"{html.escape(r['message'])}No upstream [MIG] PRs merged in the last 24h.
") + if rebase_results: + lines.append( + "{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):
{html.escape(r['repo'])} "
+ f"({html.escape(r['branch'])}): "
+ f"{html.escape(r['rebase_status'])} — "
+ f"{html.escape(r['message'])}{html.escape(r['repo'])} "
+ f"({html.escape(r['branch'])}): {html.escape(r['message'])}' @@ -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