Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions scripts/coordination/merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,12 +842,14 @@ def twin_collision_reason(
return "twin-index-collision"
cross_ref = data.get("cross_ref") or []
base_ref = data.get("base_ref") or "origin/main"
# Si AUCUN verdict n'est expose par la collision, l'organe est anterieur
# a #18683 / #18725 : on ne peut pas distinguer ON-MAIN de MULTI-PR,
# et un ON-MAIN fait rougir `main` CI des le premier merge. Fail-closed
# : on garde le motif historique ``twin-index-collision`` jusqu'a ce
# que l'organe expose son verdict. Pas de confiance silencieuse.
if not any(
# Verdict requis sur CHAQUE collision : si UNE collision n'a pas de
# verdict reconnu (organe anterieur a #18683 / #18725, ou verdict
# inconnu/None), on ne peut pas distinguer ON-MAIN de MULTI-PR, et
# un ON-MAIN ferait rougir `main` CI des le premier merge. Fail-closed
# : on garde le motif historique ``twin-index-collision`` tant que la
# classification de toutes les collisions n'est pas acquise. Pas de
# confiance silencieuse sur un mix verdict-connu + verdict-absent.
if not all(
isinstance(c, dict) and c.get("verdict") in ("ON-MAIN", "MULTI-PR")
for c in cross_ref
):
Expand Down
67 changes: 66 additions & 1 deletion scripts/tests/test_merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def __init__(
gate_stderr: str = "",
fetch_rc: int = 0,
twin_rc: int = 0,
twin_stdout: str = "",
commits: dict[str, dict] | None = None,
auto_tree: str | None = None,
):
Expand All @@ -165,6 +166,10 @@ def __init__(
self.gate_stderr = gate_stderr
self.fetch_rc = fetch_rc
self.twin_rc = twin_rc
# stdout de l'organe twin : porte le JSON classifie quand twin_rc=1.
# Vide par defaut (les tests historiques ``twin-index-collision``
# n'attendent pas de classification).
self.twin_stdout = twin_stdout
# Remontee first-parent de merge_dwell (etape 2ter) : payloads de
# ``repos/.../commits/<sha>`` et arbre rendu par ``git merge-tree``.
self.commits = commits or {}
Expand Down Expand Up @@ -234,7 +239,7 @@ def run(self, cmd: list[str], env: dict | None = None) -> mr.RunResult:
if c[:1] == ["git"] and "fetch" in c:
return mr.RunResult(self.fetch_rc, "", "")
if len(c) > 1 and "check_twin_index_collisions.py" in c[1]:
return mr.RunResult(self.twin_rc, "", "")
return mr.RunResult(self.twin_rc, self.twin_stdout, "")
raise AssertionError("commande non scriptee : " + " ".join(c))

def sleep(self, seconds: float) -> None:
Expand Down Expand Up @@ -953,6 +958,66 @@ def test_twin_fetch_failure_is_fail_closed(tmp_path):
assert not any("check_twin_index_collisions.py" in f for f in runner.flat())


def test_twin_mixed_multipr_and_unknown_then_fails_closed(tmp_path):
"""#18823 : un mix MULTI-PR + verdict absent/inconnu doit refuser, pas
laisser passer comme avertissement seul. La classification d'UNE
collision illisible ne peut pas se deduire des autres."""
view = default_view(files=(TWIN_FILE,))
twin_stdout = json.dumps({
"cross_ref": [
{"pair": "sw-5-linked-data", "index": "0012",
"verdict": "MULTI-PR"},
{"pair": "sw-5-linked-data", "index": "0012",
"verdict": None},
],
"base_ref": "origin/main",
})
runner = ScriptedRunner(views={123: view}, twin_rc=1,
twin_stdout=twin_stdout)
rc, lines, _ = run_organ(tmp_path, runner)
assert lines[0]["verdict"] == "skipped"
assert lines[0]["reason"] == "twin-index-collision"


def test_twin_pure_multipr_warns_only(tmp_path):
"""Quand toutes les collisions portent MULTI-PR, l'organe laisse passer
avec avertissement seul (la premiere mergee gagne)."""
view = default_view(files=(TWIN_FILE,))
twin_stdout = json.dumps({
"cross_ref": [
{"pair": "sw-5-linked-data", "index": "0012",
"verdict": "MULTI-PR"},
{"pair": "sw-5-linked-data", "index": "0012",
"verdict": "MULTI-PR"},
],
"base_ref": "origin/main",
})
runner = ScriptedRunner(views={123: view}, twin_rc=1,
twin_stdout=twin_stdout)
rc, lines, _ = run_organ(tmp_path, runner)
assert lines[0]["verdict"] == "would-merge"


def test_twin_pure_onmain_skips(tmp_path):
"""Quand au moins une collision est ON-MAIN, l'organe skip dur et
nomme la premiere collision ON-MAIN dans le motif."""
view = default_view(files=(TWIN_FILE,))
twin_stdout = json.dumps({
"cross_ref": [
{"pair": "sw-5-linked-data", "index": "0012",
"verdict": "ON-MAIN"},
{"pair": "sw-5-linked-data", "index": "0013",
"verdict": "MULTI-PR"},
],
"base_ref": "origin/main",
})
runner = ScriptedRunner(views={123: view}, twin_rc=1,
twin_stdout=twin_stdout)
rc, lines, _ = run_organ(tmp_path, runner)
assert lines[0]["verdict"] == "skipped"
assert lines[0]["reason"].startswith("twin-collision-on-main:sw-5-linked-data/0012")


# --- 2ter. approbation du coordinateur (Q67, arbitrage user 2026-09-28) ----------


Expand Down
Loading