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
93 changes: 93 additions & 0 deletions contracts/OMN-20978.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
schema_version: "1.0.0"
ticket_id: OMN-20978
title: "Several merged change-control companions for one PR resolve to the latest merged"
summary: scripts/ci/check_occ_companion_merged.py no longer refuses when two or more merged change-control
companions name the same product PR; it resolves to the one merged latest, so occ-preflight / eligibility
stops waiting 1500 s and failing on every repository that loads the script from this repository's dev.
is_seam_ticket: false
interface_change: false
interfaces_touched: []
requirements:
- id: req-omn20978-latest-merged
statement: A product PR named by several merged companions resolves to exactly one of them, deterministically,
instead of refusing.
acceptance:
- id: AC1
statement: 'AC1: a product PR named by two or more merged companions resolves to exactly one of
them deterministically (proposal: the highest-numbered, whose merge commit carries the earlier
evidence) instead of refusing -- falsifier: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py
-k multiple_merged red on dev, green after'
- id: req-omn20978-still-closed
statement: A companion that names another PR or is not merged never resolves, and zero merged companions
still waits.
acceptance:
- id: AC2
statement: 'AC2: a merged companion that names a different PR, or an open one, still never resolves
for this PR, and zero merged companions still waits -- falsifier: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py
-k other_pr'
- id: req-omn20978-gate-passes
statement: The companion gate passes for a PR in the omniclaude#2661 shape, two merged companions
naming one PR.
acceptance:
- id: AC3
statement: 'AC3: occ-preflight / eligibility on omniclaude#2661 passes on a rerun once the fix
is on omnibase_core dev -- falsifier: the occ-preflight / eligibility check-run conclusion on
the omniclaude#2661 head after rerun'
evidence_requirements:
- kind: tests
description: Several merged companions resolve to the latest merged one, an unorderable set still
fails closed, and the gate passes on the latest.
command: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py -k multiple_merged
-q -p no:randomly
- kind: tests
description: A companion naming another PR or an unmerged one never resolves, and zero merged companions
still waits.
command: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py -k other_pr -q -p
no:randomly
- kind: tests
description: The receipt-gate side binding resolves the latest of two merged companions.
command: uv run pytest tests/ci/test_companion_side_binding_omn18338.py -q -p no:randomly
- kind: ci
description: 'Post-merge: occ-preflight / eligibility on the omniclaude#2661 head passes on a rerun
once this change is on omnibase_core dev.'
command: onex scm pr-checks OmniNode-ai/omniclaude#2661
emergency_bypass:
enabled: false
justification: ''
follow_up_ticket_id: ''
dod_evidence:
- id: dod-omn20978-ac1
description: Two or more merged companions naming one PR resolve to the one merged latest, by merge
time and not by number, across UTC offsets; an unorderable set still fails closed.
source: manual
checks:
- check_type: test_passes
check_value: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py -k multiple_merged
-q -p no:randomly
cwd: ${OMNI_HOME}/omnibase_core
binds_ac:
- AC1
- id: dod-omn20978-ac2
description: A merged companion naming a different PR, or an unmerged one, never resolves for this
PR, and zero merged companions still waits.
source: manual
checks:
- check_type: test_passes
check_value: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py -k other_pr
-q -p no:randomly
cwd: ${OMNI_HOME}/omnibase_core
binds_ac:
- AC2
- id: dod-omn20978-ac3
description: The companion gate passes for a PR named by two merged companions (the omniclaude#2661
shape, OCC#13596 and OCC#13675), and the receipt-gate side binding resolves the latest of the two.
The live rerun on omniclaude#2661 is post-merge evidence and is listed under evidence_requirements.
source: manual
checks:
- check_type: test_passes
check_value: uv run pytest tests/unit/scripts/ci/test_check_occ_companion_merged.py tests/ci/test_companion_side_binding_omn18338.py
-k "gate_passes_on_the_latest or latest_merged_companion or latest-of-two" -q -p no:randomly
cwd: ${OMNI_HOME}/omnibase_core
binds_ac:
- AC3
91 changes: 80 additions & 11 deletions scripts/ci/check_occ_companion_merged.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import time
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol

OCC_REPO_DEFAULT = "OmniNode-ai/onex_change_control"
Expand Down Expand Up @@ -218,7 +219,7 @@ def merged_companion_candidates(
"--search",
f'"{basename}" "{pr_number}" in:title,body',
"--json",
"number,title,body,state,mergeCommit",
"number,title,body,state,mergeCommit,mergedAt,baseRefName",
"--limit",
"50",
]
Expand Down Expand Up @@ -254,7 +255,12 @@ def parse_evidence_source(body: str) -> str | None:
def select_merged_companion(
candidates: Sequence[object], repo: str, pr_number: str
) -> tuple[str | None, str]:
"""OMN-18338: resolve one distinct merged companion, failing closed on ties."""
"""OMN-18338: resolve one merged companion naming the product PR.

Several distinct merged companions resolve to the latest merged one
(OMN-20978, see :func:`_latest_merged_companion`); any shape that rule
cannot order still fails closed with an ``ambiguous`` detail.
"""
basename = repo.rsplit("/", 1)[-1]
title_re = re.compile(
rf"OCC companion for (?:{re.escape(repo.rsplit('/', 1)[0])}/)?{re.escape(basename)}"
Expand All @@ -272,7 +278,7 @@ def select_merged_companion(
batch_member_re = re.compile(
rf"^- {re.escape(repo)}#{re.escape(pr_number)}\s*$", re.MULTILINE
)
numbers: set[int] = set()
matched: dict[int, dict[str, object]] = {}
for candidate in candidates:
if not isinstance(candidate, dict):
continue
Expand All @@ -289,14 +295,75 @@ def select_merged_companion(
continue
number = candidate.get("number")
if isinstance(number, int):
numbers.add(number)
if not numbers:
matched.setdefault(number, candidate)
if not matched:
return None, "no merged companion"
if len(numbers) > 1:
refs = ", ".join(f"OCC#{number}" for number in sorted(numbers))
return None, f"ambiguous merged companions: {refs}"
ref = f"OCC#{next(iter(numbers))}"
return ref, f"merged companion {ref} resolved from the change-control record"
if len(matched) == 1:
ref = f"OCC#{next(iter(matched))}"
return ref, f"merged companion {ref} resolved from the change-control record"
return _latest_merged_companion(matched)


def _parse_merged_at(value: object) -> datetime | None:
"""A timezone-aware merge time from ``gh``'s ``mergedAt``, else ``None``."""
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None


def _latest_merged_companion(
matched: dict[int, dict[str, object]],
) -> tuple[str | None, str]:
"""OMN-20978: two or more merged companions name one product PR.

This is the ordinary state of a PR whose head moved after its first
companion merged: each head gets its own companion and each one merges.
Every one of them is durable evidence, so refusing them all (the OMN-18338
behaviour) left the PR waiting on a record that could never change.

The companion merged LAST into the shared base is the one to cite: the
change-control branch is squash-only and linear, so that merge commit has
every earlier companion's merge commit as an ancestor, and a tree checked
out at it (receipt-gate) or at a tip containing it (occ-preflight) carries
every record the earlier ones did. Picking it can therefore never hide
evidence another candidate would have shown.

That argument holds only when the candidates merged into the same branch
at distinct, readable times, so every other shape still fails closed with
an ``ambiguous`` detail: a merge time missing or unparseable, candidates
merged into different bases, or two candidates sharing the latest time.
"""
refs = ", ".join(f"OCC#{number}" for number in sorted(matched))
timed: list[tuple[datetime, int]] = []
bases: set[str] = set()
for number, candidate in matched.items():
merged_at = _parse_merged_at(candidate.get("mergedAt"))
if merged_at is None:
return None, (
f"ambiguous merged companions: {refs} (OCC#{number} has no "
"readable merge time)"
)
timed.append((merged_at, number))
bases.add(str(candidate.get("baseRefName") or ""))
if len(bases) != 1 or "" in bases:
return None, (
f"ambiguous merged companions: {refs} (merged into different or "
"unreadable base branches)"
)
timed.sort()
if timed[-1][0] == timed[-2][0]:
return None, (
f"ambiguous merged companions: {refs} (two share the latest merge time)"
)
ref = f"OCC#{timed[-1][1]}"
return ref, (
f"merged companion {ref} resolved from the change-control record as the "
f"latest merged of {refs} (OMN-20978)"
)


class MergedCompanionPort(Protocol):
Expand All @@ -313,7 +380,9 @@ def resolve_evidence_source_from_record(
"""OMN-18338: companion-side binding wins; a legacy citation is a fallback.

Unreadable and ambiguous records never license trusting a conflicting body.
The caller either retries or fails closed on ValueError.
Several merged companions that the latest-merge rule orders are not
ambiguous (OMN-20978). The caller either retries or fails closed on
ValueError.
"""
candidates = fetcher.merged_companion_candidates(occ_repo, repo, pr_number)
if candidates is None:
Expand Down
6 changes: 4 additions & 2 deletions scripts/ci/occ_preflight_wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@
The existing merged-companion gate owns the binding resolver. This poller and
receipt-gate's ``--resolve-evidence-source`` probe share it: one uniquely merged
record naming the product PR wins over an absent or conflicting body citation.
With no matching record the legacy citation path is unchanged. Ambiguous,
unreadable or capped lookups never grant a pass. No product description is
With no matching record the legacy citation path is unchanged. Several merged
records naming one PR resolve to the one merged last into their shared base
(OMN-20978). Records that rule cannot order, and unreadable or capped lookups,
never grant a pass. No product description is
rewritten; only the poll's local decision input uses the resolved reference.

The third consumer (OMN-18882)
Expand Down
35 changes: 33 additions & 2 deletions tests/ci/test_companion_side_binding_omn18338.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@
SHA = "a" * 40


def candidate(number: int = 7001, *, title: str | None = None) -> dict[str, object]:
return {
def candidate(
number: int = 7001, *, title: str | None = None, merged_at: str | None = None
) -> dict[str, object]:
record: dict[str, object] = {
"number": number,
"title": title or f"evidence(OMN-18338): OCC companion for {REPO}#2500",
"state": "MERGED",
"mergeCommit": {"oid": SHA},
}
if merged_at is not None:
record["mergedAt"] = merged_at
record["baseRefName"] = "dev"
return record


class RecordedGh(GhCli, GhFetcher):
Expand Down Expand Up @@ -74,6 +80,14 @@ def _run(self, argv: list[str]) -> str | None:
("Evidence-Source: OCC#9999", [candidate()], True),
("Evidence-Source: OCC#7001", [], True),
("description lost its line", [candidate(), candidate(7002)], False),
(
"Evidence-Source: OCC#7002",
[
candidate(merged_at="2026-10-11T14:15:00Z"),
candidate(7002, merged_at="2026-10-10T19:00:00Z"),
],
True,
),
("description lost its line", None, False),
(
"description lost its line",
Expand All @@ -87,6 +101,7 @@ def _run(self, argv: list[str]) -> str | None:
"wrong-line",
"legacy",
"ambiguous",
"latest-of-two",
"unreadable",
"number-trap",
],
Expand Down Expand Up @@ -159,6 +174,22 @@ def test_batch_companion_names_product_in_its_record() -> None:
assert sha == SHA


def test_receipt_resolution_names_latest_merged_companion(
capsys: pytest.CaptureFixture[str],
) -> None:
"""OMN-20978: the receipt-gate probe resolves several merged companions."""
records = [
candidate(7001, merged_at="2026-10-10T19:00:00Z"),
candidate(7002, merged_at="2026-10-11T14:15:00Z"),
]
rc = main(
["--repo", REPO, "--pr-number", "2500", "--resolve-evidence-source"],
gh=RecordedGh("Evidence-Source: OCC#7001", records),
)
assert rc == 0
assert capsys.readouterr().out == "OCC#7002\n"


@pytest.mark.parametrize("body", ["lost line", "Evidence-Source: OCC#7001"])
@pytest.mark.parametrize(
"records", [None, [candidate(), candidate(7002)], [candidate()] * 50]
Expand Down
Loading
Loading