diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index fa4f6df60..75324cb58 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -36,6 +36,9 @@ jobs: downstream: ${{ steps.observe.outputs.downstream_sha }} blocking_pr: ${{ steps.observe.outputs.blocking_pr_number }} blocking_head: ${{ steps.observe.outputs.blocking_pr_head_sha }} + existing_pr: ${{ steps.observe.outputs.existing_pr_number }} + existing_branch: ${{ steps.observe.outputs.existing_pr_branch }} + existing_head: ${{ steps.observe.outputs.existing_pr_head_sha }} steps: - name: Checkout trusted workflow implementation uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -98,11 +101,17 @@ jobs: EXPECTED_DOWNSTREAM: ${{ needs.observe.outputs.downstream }} EXPECTED_BLOCKING_PR: ${{ needs.observe.outputs.blocking_pr }} EXPECTED_BLOCKING_HEAD: ${{ needs.observe.outputs.blocking_head }} + EXPECTED_EXISTING_PR: ${{ needs.observe.outputs.existing_pr }} + EXPECTED_EXISTING_BRANCH: ${{ needs.observe.outputs.existing_branch }} + EXPECTED_EXISTING_HEAD: ${{ needs.observe.outputs.existing_head }} CONFIGURED_SCHEDULE: ${{ github.event.schedule || 'manual' }} run: >- python3 scripts/hosted_upstream.py --publish --expected-upstream "$EXPECTED_UPSTREAM" --expected-downstream "$EXPECTED_DOWNSTREAM" --expected-blocking-pr "$EXPECTED_BLOCKING_PR" --expected-blocking-head "$EXPECTED_BLOCKING_HEAD" + --expected-existing-pr "$EXPECTED_EXISTING_PR" + --expected-existing-branch "$EXPECTED_EXISTING_BRANCH" + --expected-existing-head "$EXPECTED_EXISTING_HEAD" --output "$RUNNER_TEMP/outcome.json" - name: Save complete publication outcome if: always() diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 9ecec6601..542242210 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -33,8 +33,9 @@ not current instruction. - Public guidance is versioned in the repository. Optional Wiki publication remains a separate operator action and cannot replace engineering authority. -- Existing upstream Draft PR #58 is an expected authenticated human-review waiting state. Do not - close, merge, rewrite, or classify it as a platform failure merely to make the queue empty. +- Current upstream Draft PR #106 is the human-controlled same-episode candidate. Reused-Draft + reconciliation requires fresh exact-main hosted evidence and preserves the Draft remote head + until the reviewed two-layer merge is ready; do not close, rewrite, or force-update it. ## Non-obvious invariants and negative knowledge diff --git a/docs/PREPARE_PR.md b/docs/PREPARE_PR.md index 31a18ecb6..a572548b8 100644 --- a/docs/PREPARE_PR.md +++ b/docs/PREPARE_PR.md @@ -145,6 +145,14 @@ Preserved upstream REVIEW/conflict publication is deliberately excluded. Prepare and updates the same Draft but neither enables auto-merge nor changes Draft readiness. Human semantic review and merge/reject authority remain mandatory for that path. +An upstream Draft reconciled after unrelated `main` movement uses the separate +`-PreserveReconciledUpstreamMerge` contract. It requires exact original candidate, pre-publication +remote Draft head, current main, reconciliation commit, final upstream parents, and reviewed final +tree identities. The reconciliation is either exact `[Draft head, current main]` or current main is +already contained by the Draft head; the final merge remains exact `[reconciliation, recorded +upstream]`. Main/head drift refuses before the same no-force fast-forward push, and the Draft remains +excluded from auto-merge. + Public fork PRs are also outside prepare-pr's auto-merge authority. The script requires an exact downstream-owned PR head, so a foreign/fork head cannot pass authentication and Mosaic automation does not arm it. A contributor without repository write permission cannot independently enable diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index ac0c21073..5809e178a 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -258,6 +258,15 @@ This parent shape lets publication fast-forward the same Draft without rewriting tree must equal the reviewed index and contain neither blocked context nor conflict markers. Ready-for-review, CI, and merge/reject remain explicit human steps. +When unrelated Mosaic main movement leaves an existing same-episode Draft on an older baseline, +the Draft is actionable only after a fresh successful Upstream Synchronization outcome on exact +current `main` binds the same episode to the exact PR number, branch, and live head. The resolver +keeps the original candidate evidence separate from this reuse proof. Starting from authenticated +Draft head `C`, it reviews an exact `B = merge(C, M)` with parents `[C, M]`; if `M` is already an +ancestor of `C`, `B = C`. Only then does it resolve the original recorded upstream `U`, producing +`R` with exact parents `[B, U]`. Reconciliation conflicts and upstream semantic conflicts are shown +as separate stages. The remote remains at `C` until one reviewed, no-force fast-forward `C -> R`. + The schedule `0 6,15,21 * * *` is UTC: approximately 08:00/17:00/23:00 Bucharest in winter and 09:00/18:00/00:00 in summer. GitHub cron does not follow DST and may start late; evidence separates configured cron from actual observation time. Complete observations retain excluded @@ -421,8 +430,11 @@ already exist. Exact attention-path overlap, shared semantic production/ownershi ancestry and downstream observation baselines form a deterministic dependency graph. A proven predecessor is `Ready for resolution`; a dependent is `Waiting on PR #N`; unrelated candidates are `Independent`. Closed/satisfied candidates are `Superseded`. Incomparable ancestry or evidence observed against an older current-main baseline is -`Dependency ambiguous` and cannot be selected. The next hosted observation must recompute stale -scope/priority against authoritative main; the local helper never rebases or overwrites a Draft. +`Dependency ambiguous` and cannot be selected. A stale same-episode Draft becomes +`Ready for current-main reconciliation` only when a retained fresh outcome authenticates exact +current main plus the exact live Draft identity. Missing/expired evidence, disagreement, head or +main drift, non-descendant Draft history, or unexplained Draft scope remains ambiguous/refused. The +local helper never rebases or overwrites a Draft. The current hosted I06 publisher still retains its earlier one-open-sync-PR guard, so normal hosted operation does not yet create concurrent independent candidates. Changing that hosted creation policy is a separate explicit follow-up, not part of this local operator helper. diff --git a/scripts/hosted_upstream.py b/scripts/hosted_upstream.py index 43a5b018c..c099be9b5 100644 --- a/scripts/hosted_upstream.py +++ b/scripts/hosted_upstream.py @@ -354,7 +354,8 @@ def technical_evidence(observation): "candidate_parents", "classification_range_count", "ownership_counts", "review_paths", "conflict_paths", "clean_path_count", "blocking_pr_number", "blocking_pr_url", "blocking_pr_head_sha", "blocking_pr_branch", "blocking_upstream_sha", - "blocking_downstream_sha", + "blocking_downstream_sha", "existing_pr_number", "existing_pr_branch", + "existing_pr_head_sha", "configured_schedule_utc", "observed_at", "run_url") if observation.get(key) is not None }, indent=2, ensure_ascii=True) @@ -773,7 +774,10 @@ def existing_candidate(git, pulls, observation, candidate, policy_version): if observation.get("episode_id") and not same[0].get("draft"): raise Blocked("Attention-required candidate is no longer Draft; preserve the human PR decision.") observation.update(outcome="existing_draft_pr" if same[0].get("draft") else "existing_pr", - pr_url=same[0]["html_url"], pr_number=same[0]["number"]) + pr_url=same[0]["html_url"], pr_number=same[0]["number"], + existing_pr_number=same[0]["number"], + existing_pr_branch=same[0]["head"]["ref"], + existing_pr_head_sha=same[0]["head"]["sha"]) return True if observation.get("episode_id"): episode = [pull for pull in pulls if pull_episode(pull) == observation["episode_id"]] @@ -785,7 +789,10 @@ def existing_candidate(git, pulls, observation, candidate, policy_version): if not pull.get("draft"): raise Blocked("The unresolved episode PR is no longer Draft; preserve the human PR decision.") observation.update(outcome="existing_draft_pr", pr_url=pull["html_url"], - pr_number=pull["number"], existing_branch=pull["head"]["ref"]) + pr_number=pull["number"], existing_branch=pull["head"]["ref"], + existing_pr_number=pull["number"], + existing_pr_branch=pull["head"]["ref"], + existing_pr_head_sha=pull["head"]["sha"]) return True if episode: raise Blocked("The unresolved episode has a closed PR decision; do not automatically reopen or recreate it.") @@ -963,7 +970,8 @@ def inspect(git, github, observation, anchor=INITIAL_ANCHOR): def publish(git, github, observation, expected_up, expected_down, - expected_blocking_pr="", expected_blocking_head=""): + expected_blocking_pr="", expected_blocking_head="", expected_existing_pr="", + expected_existing_branch="", expected_existing_head=""): if (observation.get("upstream_sha"), observation.get("downstream_sha")) != (expected_up, expected_down): raise Blocked("Refs changed between read and publish jobs; rerun to observe current inputs.") expected_wait = bool(str(expected_blocking_pr).strip() or str(expected_blocking_head).strip()) @@ -978,10 +986,25 @@ def publish(git, github, observation, expected_up, expected_down, return if expected_wait: raise Blocked("Blocking PR state changed between read and publish jobs; rerun safely.") + expected_existing = tuple(str(value).strip() for value in ( + expected_existing_pr, expected_existing_branch, expected_existing_head + )) + has_expected_existing = any(expected_existing) + if has_expected_existing and not all(expected_existing): + raise Blocked("Observe handoff contains an incomplete existing Draft identity.") if observation["outcome"] in {"no_delta", "observed_excluded"}: + if has_expected_existing: + raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.") return if observation["outcome"] in {"existing_pr", "existing_draft_pr"}: + actual_existing = tuple(str(observation.get(key) or "") for key in ( + "existing_pr_number", "existing_pr_branch", "existing_pr_head_sha" + )) + if not has_expected_existing or actual_existing != expected_existing: + raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.") return + if has_expected_existing: + raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.") if observation["outcome"] not in {"ready", "review_required", "semantic_conflict"}: raise Blocked("Observation is not a publishable candidate.") token_present = bool(os.environ.get("SYNC_PUBLISH_TOKEN", "").strip()) @@ -1107,10 +1130,13 @@ def main(): parser.add_argument("--expected-downstream", default="") parser.add_argument("--expected-blocking-pr", default="") parser.add_argument("--expected-blocking-head", default="") + parser.add_argument("--expected-existing-pr", default="") + parser.add_argument("--expected-existing-branch", default="") + parser.add_argument("--expected-existing-head", default="") parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() raw_repository = os.environ.get("GITHUB_REPOSITORY", "") - o = {"schema_version": 1, "upstream_repo": UPSTREAM, "upstream_ref": "refs/heads/main", + o = {"schema_version": 2, "upstream_repo": UPSTREAM, "upstream_ref": "refs/heads/main", "downstream_repo": raw_repository, "downstream_ref": "refs/heads/main", "observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "configured_schedule_utc": os.environ.get("CONFIGURED_SCHEDULE") or "manual", @@ -1140,7 +1166,9 @@ def main(): inspect(git, github, o) if args.publish: publish(git, github, o, args.expected_upstream, args.expected_downstream, - args.expected_blocking_pr, args.expected_blocking_head) + args.expected_blocking_pr, args.expected_blocking_head, + args.expected_existing_pr, args.expected_existing_branch, + args.expected_existing_head) except (Blocked, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc: failed = True operation_error = isinstance(exc, OperationError) or not isinstance(exc, Blocked) @@ -1152,7 +1180,9 @@ def main(): if os.environ.get("GITHUB_OUTPUT"): with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: for key in ("outcome", "upstream_sha", "downstream_sha", - "blocking_pr_number", "blocking_pr_head_sha"): + "blocking_pr_number", "blocking_pr_head_sha", + "existing_pr_number", "existing_pr_branch", + "existing_pr_head_sha"): stream.write(f"{key}={o.get(key, '')}\n") if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: diff --git a/scripts/prepare-pr.ps1 b/scripts/prepare-pr.ps1 index 7bfe3ee80..7b6694c89 100644 --- a/scripts/prepare-pr.ps1 +++ b/scripts/prepare-pr.ps1 @@ -14,11 +14,21 @@ param( [switch]$NoFetch, [switch]$NonInteractive, [switch]$PreserveMergeCommit, + [switch]$PreserveReconciledUpstreamMerge, + [string]$ExpectedRemoteDraftHead, + [string]$ExpectedOriginalCandidate, + [string]$ExpectedCurrentMain, + [string]$ExpectedReconciliationCommit, [string]$ExpectedMergeFirstParent, [string]$ExpectedMergeSecondParent, [string]$ExpectedMergeTree ) +if ($PreserveMergeCommit -and $PreserveReconciledUpstreamMerge) { + throw 'Choose exactly one upstream merge-preservation mode.' +} +$preserveUpstreamCommit = $PreserveMergeCommit -or $PreserveReconciledUpstreamMerge + $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot $configPath = Join-Path $PSScriptRoot 'prepare-pr.config.psd1' @@ -668,7 +678,7 @@ function Resolve-AndSaveScope([object]$Preflight) { if ($Files.Count -or $Exclude.Count) { throw 'Committed-only publication always uses the complete branch diff; -Files and -Exclude are not applicable.' } - if ($Preflight.Branch -like $config.UpstreamSyncBranchPattern -and -not $PreserveMergeCommit) { + if ($Preflight.Branch -like $config.UpstreamSyncBranchPattern -and -not $preserveUpstreamCommit) { throw 'A committed-only upstream-sync branch requires the existing preserved native-merge identity arguments.' } Write-Section 'AUDIT CHANGES' @@ -916,7 +926,7 @@ function Invoke-Commit([object]$Preflight, [object]$State) { Write-Host "Title: $commitTitle" Invoke-Git -Arguments @('diff', '--cached', '--stat') | Select-Object -ExpandProperty Output | ForEach-Object { Write-Host $_ } } - if ($PreserveMergeCommit) { + if ($preserveUpstreamCommit) { if ($Preflight.Branch -notlike $config.UpstreamSyncBranchPattern) { throw '-PreserveMergeCommit is restricted to an upstream-sync branch.' } foreach ($identity in @($ExpectedMergeFirstParent, $ExpectedMergeSecondParent, $ExpectedMergeTree)) { if ($identity -notmatch '^[0-9a-f]{40}$') { throw 'Preserved merge identity requires exact lowercase 40-character Git object IDs.' } @@ -933,7 +943,36 @@ function Invoke-Commit([object]$Preflight, [object]$State) { $remote = Invoke-Git -Arguments @('ls-remote', '--heads', $config.OriginRemote, $remoteRef) -AllowFailure if ($remote.ExitCode -ne 0) { throw 'Could not authenticate the existing Draft branch.' } $remoteHead = (($remote.Output | Select-Object -First 1) -split '\s+')[0] - if ($remoteHead -ne $ExpectedMergeFirstParent) { throw 'Existing Draft branch moved; preserved merge publication is refused.' } + if ($PreserveReconciledUpstreamMerge) { + foreach ($identity in @($ExpectedRemoteDraftHead, $ExpectedOriginalCandidate, + $ExpectedCurrentMain, $ExpectedReconciliationCommit)) { + if ($identity -notmatch '^[0-9a-f]{40}$') { + throw 'Reconciled upstream preservation requires exact lowercase 40-character Git object IDs.' + } + } + if ($remoteHead -ne $ExpectedRemoteDraftHead) { throw 'Existing Draft branch moved; reconciled publication is refused.' } + if ($ExpectedMergeFirstParent -ne $ExpectedReconciliationCommit) { throw 'Final merge first parent is not the authenticated reconciliation commit.' } + $anchor = Invoke-Git -Arguments @('merge-base', '--is-ancestor', $ExpectedOriginalCandidate, $ExpectedRemoteDraftHead) -AllowFailure + if ($anchor.ExitCode -ne 0) { throw 'Remote Draft head does not descend from the original candidate.' } + $reconciliationParents = @((Get-GitText @('show', '-s', '--format=%P', $ExpectedReconciliationCommit)) -split '\s+' | Where-Object { $_ }) + if ($ExpectedReconciliationCommit -eq $ExpectedRemoteDraftHead) { + $containsMain = Invoke-Git -Arguments @('merge-base', '--is-ancestor', $ExpectedCurrentMain, $ExpectedRemoteDraftHead) -AllowFailure + if ($containsMain.ExitCode -ne 0) { throw 'Draft head does not contain authenticated current main.' } + } elseif ($reconciliationParents.Count -ne 2 -or + $reconciliationParents[0] -ne $ExpectedRemoteDraftHead -or + $reconciliationParents[1] -ne $ExpectedCurrentMain) { + throw 'Reconciliation commit does not have exact parents [remote Draft head, current main].' + } + if ((Get-GitText @('rev-parse', "$($config.OriginRemote)/$($config.BaseBranch)")) -ne $ExpectedCurrentMain) { + throw 'Current protected main moved after reconciliation review.' + } + foreach ($ancestorIdentity in @($ExpectedRemoteDraftHead, $ExpectedCurrentMain, $ExpectedMergeSecondParent)) { + $contains = Invoke-Git -Arguments @('merge-base', '--is-ancestor', $ancestorIdentity, 'HEAD') -AllowFailure + if ($contains.ExitCode -ne 0) { throw 'Final reconciled merge is missing an authenticated ancestor.' } + } + } elseif ($remoteHead -ne $ExpectedMergeFirstParent) { + throw 'Existing Draft branch moved; preserved merge publication is refused.' + } $commitTitle = Get-GitText @('show', '-s', '--format=%s', 'HEAD') if ($script:conciseMode) { Write-Host 'Preserving reviewed native merge commit.' } else { Write-Host "Preserving existing merge commit: $commit" } } else { @@ -1042,14 +1081,15 @@ function Invoke-Publish([object]$Preflight, [object]$State) { $branch = $Preflight.Branch $originUrl = Get-GitText @('remote', 'get-url', $config.OriginRemote) $slug = Get-RepositorySlug $originUrl - if ($PreserveMergeCommit) { + if ($preserveUpstreamCommit) { $beforeResult = Invoke-Gh -CommandPath $gh.Source -Arguments @('pr', 'list', '--repo', $slug, '--base', $config.BaseBranch, '--head', $branch, '--state', 'open', '--json', 'number,url,isDraft,headRefOid') -AllowFailure if ($beforeResult.ExitCode -ne 0) { throw "GitHub CLI could not authenticate the existing Draft PR before push:`n$($beforeResult.Output -join [Environment]::NewLine)" } $beforeJson = ($beforeResult.Output -join "`n").Trim() $beforePullRequests = if ($beforeJson) { @($beforeJson | ConvertFrom-Json) } else { @() } if ($beforePullRequests.Count -ne 1) { throw 'Expected exactly one existing Draft PR before preserved merge publication.' } if (-not $beforePullRequests[0].isDraft) { throw 'The existing upstream PR is no longer Draft; no push occurred.' } - if ($beforePullRequests[0].headRefOid -ne $ExpectedMergeFirstParent) { throw 'Existing Draft PR moved before publication; no push occurred.' } + $expectedBeforeHead = if ($PreserveReconciledUpstreamMerge) { $ExpectedRemoteDraftHead } else { $ExpectedMergeFirstParent } + if ($beforePullRequests[0].headRefOid -ne $expectedBeforeHead) { throw 'Existing Draft PR moved before publication; no push occurred.' } } $remoteRef = "refs/heads/$branch" $remoteQuery = Invoke-Git -Arguments @('ls-remote', '--heads', $config.OriginRemote, $remoteRef) -AllowFailure @@ -1057,6 +1097,13 @@ function Invoke-Publish([object]$Preflight, [object]$State) { $remoteExists = [bool](($remoteQuery.Output -join '').Trim()) if ($remoteExists) { $remoteCommit = (($remoteQuery.Output | Select-Object -First 1) -split '\s+')[0] + if ($PreserveReconciledUpstreamMerge) { + if ($remoteCommit -ne $ExpectedRemoteDraftHead) { throw 'Remote Draft head moved before reconciled publication.' } + Invoke-Git -Arguments @('fetch', '--no-tags', $config.OriginRemote, $config.BaseBranch) | Out-Null + if ((Get-GitText @('rev-parse', "$($config.OriginRemote)/$($config.BaseBranch)")) -ne $ExpectedCurrentMain) { + throw 'Current protected main moved before reconciled publication.' + } + } Invoke-Git -Arguments @('fetch', '--no-tags', $config.OriginRemote, $remoteRef) | Out-Null $fastForward = Invoke-Git -Arguments @('merge-base', '--is-ancestor', $remoteCommit, 'HEAD') -AllowFailure if ($fastForward.ExitCode -ne 0) { throw 'Remote branch is divergent or ahead; publication would require a force push. Refusing.' } @@ -1074,7 +1121,7 @@ function Invoke-Publish([object]$Preflight, [object]$State) { $existingPullRequests = if ($existingJson) { @($existingJson | ConvertFrom-Json) } else { @() } if ($existingPullRequests.Count -gt 1) { throw 'Multiple open PRs match the published branch; refusing ambiguous PR identity.' } $existing = @($existingPullRequests | ForEach-Object { "#$($_.number) $($_.url)" }) - if ($PreserveMergeCommit) { + if ($preserveUpstreamCommit) { if ($existingPullRequests.Count -ne 1) { throw 'Expected exactly one existing Draft PR for the preserved upstream merge.' } if (-not $existingPullRequests[0].isDraft) { throw 'The existing upstream PR is no longer Draft; preserve the human readiness decision.' } if ($existingPullRequests[0].headRefOid -ne $State.commit) { throw 'Existing Draft PR head does not match the reviewed upstream merge commit.' } @@ -1102,7 +1149,7 @@ function Invoke-Publish([object]$Preflight, [object]$State) { $pullRequest = $existingPullRequests[0] $openLink = Format-MosaicTerminalLink '[open]' ([string]$pullRequest.url) ([string]$pullRequest.url) Write-Host "PR #$($pullRequest.number) $prDisposition $openLink" - if ($PreserveMergeCommit) { + if ($preserveUpstreamCommit) { Write-Host 'Auto-merge: EXCLUDED (upstream Draft requires human review).' Write-Host 'Review and resolve Draft readiness in GitHub.' $autoMergeResult = 'excluded upstream Draft' diff --git a/scripts/resolve_upstream.py b/scripts/resolve_upstream.py index 78d5fe1fe..6327875e9 100644 --- a/scripts/resolve_upstream.py +++ b/scripts/resolve_upstream.py @@ -25,6 +25,7 @@ REPOSITORY = MOSAIC_DOWNSTREAM_REPOSITORY UPSTREAM = UPSTREAM_REPOSITORY BASE_BRANCH = "main" +UPSTREAM_WORKFLOW_PATH = ".github/workflows/upstream-sync.yml" BRANCH_PREFIX = "chore/sync-upstream-" EPISODE = re.compile(r"") TECHNICAL = re.compile( @@ -58,6 +59,9 @@ class Candidate: state: str = "Independent" predecessor: int | None = None overlaps: tuple[str, ...] = () + current_observation: dict | None = None + current_main: str | None = None + refusal: str | None = None class Runner: @@ -208,6 +212,96 @@ def load_observation(runner: Runner, root: Path, pr: dict, episode: str, return observation, run_url +def load_current_reuse_observation(runner: Runner, root: Path, pr: dict, original: dict, + current_main: str, repository=REPOSITORY) -> dict: + """Authenticate fresh hosted proof that one stale Draft remains the same episode.""" + repository = authenticate_downstream_repository(repository) + original_run = RUN_ID.search(str(original.get("run_url") or "")) + original_run_id = int(original_run.group(1)) if original_run else 0 + response = json_output(runner, [ + "gh", "api", f"repos/{repository}/actions/workflows/upstream-sync.yml/runs" + f"?branch={BASE_BRANCH}&status=success&per_page=100", + ], root) + runs = response.get("workflow_runs") if isinstance(response, dict) else None + if not isinstance(runs, list): + raise Refusal("Could not enumerate authenticated Upstream Synchronization outcomes.") + matches = [] + with tempfile.TemporaryDirectory(prefix="wholphin-upstream-current-") as directory: + base = Path(directory) + for run in runs: + run_id = int(run.get("id") or 0) + attempt = int(run.get("run_attempt") or 0) + if (run_id <= original_run_id or attempt <= 0 + or run.get("head_sha") != current_main + or run.get("head_branch") != BASE_BRANCH + or run.get("path") != UPSTREAM_WORKFLOW_PATH + or run.get("conclusion") != "success" + or run.get("event") not in {"schedule", "workflow_dispatch"}): + continue + destination = base / f"{run_id}-{attempt}" + destination.mkdir() + downloaded = runner.run([ + "gh", "run", "download", str(run_id), "--repo", repository, + "--name", f"upstream-outcome-{attempt}", "--dir", str(destination), + ], cwd=root, check=False) + evidence_file = destination / "outcome.json" + if downloaded.returncode or not evidence_file.is_file(): + continue + try: + evidence = json.loads(evidence_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise Refusal("A current-main upstream outcome artifact is unreadable.") from error + expected = { + "schema_version": 2, + "downstream_repo": repository, + "downstream_sha": current_main, + "episode_id": marker(pr.get("body", "")), + "ownership_policy_version": original.get("ownership_policy_version"), + "outcome": "existing_draft_pr", + "existing_pr_number": int(pr["number"]), + "existing_pr_branch": pr["head"]["ref"], + "existing_pr_head_sha": pr["head"]["sha"], + "run_id": str(run_id), + "run_attempt": str(attempt), + } + if all(str(evidence.get(key)) == str(value) for key, value in expected.items()): + observed_candidate = { + "head": { + "ref": evidence.get("branch"), + "sha": evidence.get("candidate_sha"), + } + } + validate_observation( + evidence, observed_candidate, expected["episode_id"], runner=runner, root=root, + run_id=run_id, attempt=attempt, repository=repository, + ) + original_anchor = original.get("candidate_sha") + live_head = pr.get("head", {}).get("sha") + if (not original_anchor or not live_head + or not is_ancestor(runner, root, repository, original_anchor, live_head)): + raise Refusal( + "The selected Draft head is not a proven descendant of its original candidate." + ) + matches.append(evidence) + if not matches: + raise Refusal( + "No fresh authenticated same-episode Upstream Synchronization outcome exists for " + "current origin/main. Run the normal Upstream Synchronization workflow and retry." + ) + critical = { + (item.get("episode_id"), item.get("downstream_sha"), item.get("upstream_sha"), + item.get("ownership_policy_version"), item.get("existing_pr_number"), + item.get("existing_pr_branch"), item.get("existing_pr_head_sha"), + item.get("candidate_sha"), item.get("candidate_tree"), + item.get("comparison_baseline"), item.get("classification_range_count"), + tuple(item.get("review_paths") or ()), tuple(item.get("conflict_paths") or ())) + for item in matches + } + if len(critical) != 1: + raise Refusal("Current-main upstream outcomes disagree on resolver-critical identity.") + return max(matches, key=lambda item: int(item.get("run_id") or 0)) + + def checks(runner: Runner, root: Path, number: int, repository=REPOSITORY) -> dict: repository = authenticate_downstream_repository(repository) result = runner.run([ @@ -230,7 +324,8 @@ def checks(runner: Runner, root: Path, number: int, repository=REPOSITORY) -> di return {"status": status, "name": name or None, "url": row.get("link")} -def assert_preflight(runner: Runner, root: Path, *, allow_dirty=False) -> tuple[str, str]: +def assert_preflight(runner: Runner, root: Path, *, allow_dirty=False, + allow_resolution_merge=False) -> tuple[str, str]: if not shutil.which("git"): raise Refusal("Git is required and was not found on PATH.") if not shutil.which("gh"): @@ -249,8 +344,12 @@ def assert_preflight(runner: Runner, root: Path, *, allow_dirty=False) -> tuple[ upstream = runner.run(["git", "remote", "get-url", "upstream"], cwd=root).stdout.strip() if slug(upstream) != UPSTREAM: raise Refusal(f"Expected upstream {UPSTREAM}; found {slug(upstream) or upstream}.") - dirty = runner.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=root).stdout.strip() branch = runner.run(["git", "branch", "--show-current"], cwd=root).stdout.strip() + merge_head = runner.run(["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], + cwd=root, check=False).stdout.strip() + dirty = runner.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=root).stdout.strip() + if merge_head and not (allow_resolution_merge and branch.startswith(BRANCH_PREFIX)): + raise Refusal("An active merge must be completed or aborted manually before resolution.") if dirty and not allow_dirty: raise Refusal("Working tree is not clean. Commit or preserve local work separately; nothing was stashed or discarded.") auth = runner.run(["gh", "auth", "status", "--hostname", "github.com"], cwd=root, check=False) @@ -346,7 +445,27 @@ def classify_dependencies(runner: Runner, root: Path, candidates: list[Candidate continue observed_main = candidate.observation.get("downstream_sha") if observed_main != main_sha: - candidate.state = "Dependency ambiguous" + if not observed_main or not is_ancestor( + runner, root, repository, observed_main, main_sha): + candidate.state = "Dependency ambiguous" + continue + try: + current = load_current_reuse_observation( + runner, root, candidate.pr, candidate.observation, main_sha, repository + ) + except Refusal as error: + candidate.state = "Dependency ambiguous" + candidate.refusal = str(error) + continue + original_upstream = candidate.observation.get("upstream_sha") + current_upstream = current.get("upstream_sha") + if (not original_upstream or not current_upstream + or not is_ancestor(runner, root, UPSTREAM, original_upstream, current_upstream)): + candidate.state = "Dependency ambiguous" + continue + candidate.current_observation = current + candidate.current_main = main_sha + candidate.state = "Ready for current-main reconciliation" active = [candidate for candidate in active if candidate.state not in {"Superseded", "Dependency ambiguous"}] edges: dict[int, set[int]] = {int(candidate.pr["number"]): set() for candidate in active} for index, left in enumerate(active): @@ -383,6 +502,8 @@ def classify_dependencies(runner: Runner, root: Path, candidates: list[Candidate if predecessors: candidate.state = f"Waiting on PR #{predecessors[0]}" candidate.predecessor = predecessors[0] + elif candidate.state == "Ready for current-main reconciliation": + continue elif len(active) == 1 or any(edges.values()): candidate.state = "Ready for resolution" else: @@ -438,25 +559,27 @@ def blocked_context(runner: Runner, root: Path, candidate: Candidate) -> dict: raise Refusal("Candidate branch does not encode an exact upstream/downstream SHA pair.") upstream, downstream = match.groups() observation = candidate.observation + original_candidate = observation.get("candidate_sha") expected = { - "candidate_sha": candidate.pr["head"]["sha"], "upstream_sha": upstream, "downstream_sha": downstream, } for key, value in expected.items(): if observation.get(key) != value: raise Refusal(f"Incomplete or mismatched conflict evidence for {key}.") - parents = git_text(runner, root, "show", "-s", "--format=%P", expected["candidate_sha"]).split() + if not original_candidate: + raise Refusal("Original candidate evidence does not contain a candidate anchor.") + parents = git_text(runner, root, "show", "-s", "--format=%P", original_candidate).split() if parents != [downstream]: raise Refusal("Blocked candidate is not the exact single-parent downstream workspace.") - candidate_tree = git_text(runner, root, "rev-parse", expected["candidate_sha"] + "^{tree}") + candidate_tree = git_text(runner, root, "rev-parse", original_candidate + "^{tree}") if observation.get("candidate_tree") and observation["candidate_tree"] != candidate_tree: raise Refusal("Blocked candidate tree differs from machine evidence.") raw = git_text( runner, root, "show", - expected["candidate_sha"] + ":.upstream-sync/blocked-context.json", + original_candidate + ":.upstream-sync/blocked-context.json", ) try: context = json.loads(raw) @@ -472,6 +595,27 @@ def blocked_context(runner: Runner, root: Path, candidate: Candidate) -> dict: return context +def validate_draft_extension_scope(runner: Runner, root: Path, candidate: Candidate) -> list[str]: + """Require every human commit between original A and live C to stay in reviewed scope.""" + anchor = candidate.observation.get("candidate_sha") + live_head = candidate.pr["head"]["sha"] + if not anchor or anchor == live_head: + return [] + paths = sorted(set(git_text( + runner, root, "diff", "--no-renames", "--name-only", anchor, live_head, "--", + ).splitlines())) + attention = set(candidate.observation.get("review_paths") or []) | set( + candidate.observation.get("conflict_paths") or [] + ) + unexpected = sorted(set(paths) - attention) + if unexpected: + raise Refusal( + "Draft descendant contains unexplained paths outside original review scope: " + + ", ".join(unexpected) + ) + return paths + + def authenticate_upstream_history(runner: Runner, root: Path, upstream: str) -> None: runner.run([ "git", "fetch", "--quiet", "--no-tags", "upstream", @@ -485,12 +629,13 @@ def authenticate_upstream_history(runner: Runner, root: Path, upstream: str) -> raise Refusal("Recorded upstream tip is missing or no longer belongs to current upstream history.") -def begin_native_resolution(runner: Runner, root: Path, candidate: Candidate) -> None: +def begin_native_resolution(runner: Runner, root: Path, candidate: Candidate, + first_parent: str | None = None) -> None: context = blocked_context(runner, root, candidate) upstream = context["upstream"] - candidate_sha = candidate.pr["head"]["sha"] - if git_text(runner, root, "rev-parse", "HEAD") != candidate_sha: - raise Refusal("Checked-out candidate moved before native merge initialization.") + expected_head = first_parent or candidate.pr["head"]["sha"] + if git_text(runner, root, "rev-parse", "HEAD") != expected_head: + raise Refusal("Checked-out reconciliation head moved before native merge initialization.") authenticate_upstream_history(runner, root, upstream) merge = runner.run(["git", "merge", "--no-ff", "--no-commit", upstream], cwd=root, check=False) merge_head = git_text(runner, root, "rev-parse", "MERGE_HEAD", check=False) @@ -499,9 +644,10 @@ def begin_native_resolution(runner: Runner, root: Path, candidate: Candidate) -> runner.run(["git", "rm", "-f", "--", ".upstream-sync/blocked-context.json"], cwd=root) -def assert_native_merge_identity(runner: Runner, root: Path, candidate: Candidate) -> dict: +def assert_native_merge_identity(runner: Runner, root: Path, candidate: Candidate, + first_parent: str | None = None) -> dict: context = blocked_context(runner, root, candidate) - expected_first = candidate.pr["head"]["sha"] + expected_first = first_parent or candidate.pr["head"]["sha"] expected_second = context["upstream"] if git_text(runner, root, "rev-parse", "HEAD") != expected_first: raise Refusal("Native resolution HEAD is not the exact remote Draft candidate.") @@ -510,8 +656,9 @@ def assert_native_merge_identity(runner: Runner, root: Path, candidate: Candidat return context -def assert_resolved_native_merge(runner: Runner, root: Path, candidate: Candidate) -> str: - assert_native_merge_identity(runner, root, candidate) +def assert_resolved_native_merge(runner: Runner, root: Path, candidate: Candidate, + first_parent: str | None = None) -> str: + assert_native_merge_identity(runner, root, candidate, first_parent) unmerged = git_text(runner, root, "diff", "--name-only", "--diff-filter=U") if unmerged: raise Refusal("Native merge still has unresolved paths: " + ", ".join(unmerged.splitlines())) @@ -532,7 +679,8 @@ def assert_resolved_native_merge(runner: Runner, root: Path, candidate: Candidat return git_text(runner, root, "write-tree") -def commit_native_resolution(runner: Runner, root: Path, candidate: Candidate, paths: list[str]) -> tuple[str, str]: +def commit_native_resolution(runner: Runner, root: Path, candidate: Candidate, paths: list[str], + first_parent: str | None = None) -> tuple[str, str]: stageable = [] for path in paths: tracked = runner.run(["git", "ls-files", "--error-unmatch", "--", path], @@ -541,8 +689,8 @@ def commit_native_resolution(runner: Runner, root: Path, candidate: Candidate, p stageable.append(path) if stageable: runner.run(["git", "add", "-A", "--", *stageable], cwd=root) - tree = assert_resolved_native_merge(runner, root, candidate) - first = candidate.pr["head"]["sha"] + tree = assert_resolved_native_merge(runner, root, candidate, first_parent) + first = first_parent or candidate.pr["head"]["sha"] second = candidate.observation["upstream_sha"] runner.run([ "git", "commit", "-m", f"Resolve official upstream {second} against Mosaic {first}" @@ -557,6 +705,112 @@ def commit_native_resolution(runner: Runner, root: Path, candidate: Candidate, p return commit, tree +def commit_clean_reconciled_resolution(runner: Runner, root: Path, candidate: Candidate, + paths: list[str], first_parent: str) -> tuple[str, str]: + """Create exact R=[B,U] when U is already contained and Git has no active merge.""" + if git_text(runner, root, "rev-parse", "HEAD") != first_parent: + raise Refusal("Clean upstream review is not based on authenticated reconciliation B.") + for path in paths: + runner.run(["git", "add", "-A", "--", path], cwd=root) + runner.run(["git", "diff", "--cached", "--check"], cwd=root) + tree = git_text(runner, root, "write-tree") + second = candidate.observation["upstream_sha"] + commit = runner.run( + ["git", "commit-tree", tree, "-p", first_parent, "-p", second, "-m", + f"Resolve official upstream {second} against reconciled Mosaic {first_parent}"], + cwd=root, check=True, + ) + resolved = commit.stdout.strip() + runner.run(["git", "update-ref", "HEAD", resolved, first_parent], cwd=root) + parents = git_text(runner, root, "show", "-s", "--format=%P", resolved).split() + if parents != [first_parent, second]: + raise Refusal("Clean upstream resolution does not have exact parents [B,U].") + if git_text(runner, root, "rev-parse", resolved + "^{tree}") != tree: + raise Refusal("Clean upstream resolution tree differs from the reviewed index.") + return resolved, tree + + +def reconciliation_commit(runner: Runner, root: Path, candidate: Candidate) -> str: + """Finish the separately reviewed C+M merge and authenticate B=[C,M].""" + remote_head = candidate.pr["head"]["sha"] + current_main = candidate.current_main + if not current_main: + return remote_head + merge_head = git_text(runner, root, "rev-parse", "-q", "--verify", "MERGE_HEAD", check=False) + if merge_head != current_main: + raise Refusal("Current-main reconciliation MERGE_HEAD does not match authenticated main.") + unmerged = git_text(runner, root, "diff", "--name-only", "--diff-filter=U") + if unmerged: + raise Refusal( + "Current-main reconciliation still has unresolved paths: " + + ", ".join(unmerged.splitlines()) + ) + runner.run(["git", "diff", "--cached", "--check"], cwd=root) + reviewed_tree = git_text(runner, root, "write-tree") + runner.run([ + "git", "commit", "-m", f"Reconcile Mosaic main {current_main} into upstream Draft {remote_head}" + ], cwd=root) + commit = git_text(runner, root, "rev-parse", "HEAD") + parents = git_text(runner, root, "show", "-s", "--format=%P", commit).split() + if parents != [remote_head, current_main]: + raise Refusal("Reconciliation commit does not have exact parents [Draft head, current main].") + if git_text(runner, root, "rev-parse", commit + "^{tree}") != reviewed_tree: + raise Refusal("Reconciliation commit tree differs from the reviewed reconciliation tree.") + return commit + + +def begin_main_reconciliation(runner: Runner, root: Path, candidate: Candidate) -> str: + """Start, but never silently complete, the authenticated C+M review stage.""" + remote_head = candidate.pr["head"]["sha"] + current_main = candidate.current_main + if not current_main: + return remote_head + runner.run([ + "git", "fetch", "--no-tags", "origin", + f"refs/heads/{BASE_BRANCH}:refs/remotes/origin/{BASE_BRANCH}", + ], cwd=root) + fetched_main = git_text(runner, root, "rev-parse", f"refs/remotes/origin/{BASE_BRANCH}") + if fetched_main != current_main: + raise Refusal("Current Mosaic main moved after hosted reuse evidence; reobserve and retry.") + head = git_text(runner, root, "rev-parse", "HEAD") + if head != remote_head: + raise Refusal("Local Draft head differs from the exact authenticated remote head.") + contains_main = runner.run( + ["git", "merge-base", "--is-ancestor", current_main, remote_head], + cwd=root, check=False, + ) + if contains_main.returncode == 0: + return remote_head + merge = runner.run(["git", "merge", "--no-ff", "--no-commit", current_main], + cwd=root, check=False) + merge_head = git_text(runner, root, "rev-parse", "-q", "--verify", "MERGE_HEAD", check=False) + if merge.returncode not in (0, 1) or merge_head != current_main: + raise Refusal("Git could not establish the exact current-main reconciliation state.") + return "" + + +def reconciliation_prompt(candidate: Candidate, runner: Runner, root: Path, + draft_extension_paths: list[str] | None = None) -> str: + conflicts = git_text(runner, root, "diff", "--name-only", "--diff-filter=U").splitlines() + lines = [ + f"# Reconcile Mosaic main for Upstream Sync PR #{candidate.pr['number']}", "", + "This is the Mosaic-main reconciliation stage, not upstream semantic resolution.", "", + f"- Remote Draft head C: `{candidate.pr['head']['sha']}`", + f"- Authenticated current main M: `{candidate.current_main}`", "", + ] + if conflicts: + lines += ["Resolve only these C + M reconciliation conflicts:", ""] + lines += [f"- `{path}`" for path in conflicts] + else: + lines += ["The C + M merge is textually clean. Review the complete staged reconciliation tree."] + if draft_extension_paths: + lines += ["", "Authenticated human changes already present between original A and live C:", ""] + lines += [f"- `{path}`" for path in draft_extension_paths] + lines += ["", "Do not begin upstream resolution manually.", + "After review and conflict resolution, rerun `resolve-upstream.ps1` for this PR."] + return "\n".join(lines) + "\n" + + def resolution_paths(runner: Runner, root: Path, remote_sha: str) -> list[str]: paths = set() commands = ( @@ -571,6 +825,28 @@ def resolution_paths(runner: Runner, root: Path, remote_sha: str) -> list[str]: return sorted(paths) +def validate_reconciliation_scope(runner: Runner, root: Path, candidate: Candidate, + reconciliation: str) -> list[str]: + if not candidate.current_main or reconciliation == candidate.pr["head"]["sha"]: + return [] + remote = candidate.pr["head"]["sha"] + recorded_downstream = candidate.observation["downstream_sha"] + main_paths = set(git_text( + runner, root, "diff", "--no-renames", "--name-only", + recorded_downstream, candidate.current_main, "--", + ).splitlines()) + reconciliation_paths = set(git_text( + runner, root, "diff", "--no-renames", "--name-only", remote, reconciliation, "--", + ).splitlines()) + unexpected = sorted(reconciliation_paths - main_paths) + if unexpected: + raise Refusal( + "Current-main reconciliation contains paths outside the authenticated main delta: " + + ", ".join(unexpected) + ) + return sorted(reconciliation_paths) + + def derive_filters(paths: list[str], attention: list[str]) -> list[str]: if not paths: raise Refusal("No semantic-resolution changes exist; publication is not needed.") @@ -647,15 +923,26 @@ def verify_local_descendant(runner: Runner, root: Path, candidate: Candidate) -> raise Refusal("Local candidate branch is not a normal descendant of the remote PR head.") -def prepare_command(root: Path, filters: list[str], first=None, second=None, tree=None) -> list[str]: +def prepare_command(root: Path, filters: list[str], first=None, second=None, tree=None, *, + remote_head=None, original_candidate=None, current_main=None, + reconciliation_commit=None) -> list[str]: def quote(value): return "'" + str(value).replace("'", "''") + "'" script = quote(root / "scripts" / "prepare-pr.ps1") filter_array = "@(" + ",".join(quote(value) for value in filters) + ")" command = f"& {script} -TestFilter {filter_array}" if all((first, second, tree)): + if all((remote_head, original_candidate, current_main, reconciliation_commit)): + command += ( + f" -PreserveReconciledUpstreamMerge -ExpectedRemoteDraftHead {quote(remote_head)} " + f"-ExpectedOriginalCandidate {quote(original_candidate)} " + f"-ExpectedCurrentMain {quote(current_main)} " + f"-ExpectedReconciliationCommit {quote(reconciliation_commit)}" + ) + else: + command += " -PreserveMergeCommit" command += ( - f" -PreserveMergeCommit -ExpectedMergeFirstParent {quote(first)} " + f" -ExpectedMergeFirstParent {quote(first)} " f"-ExpectedMergeSecondParent {quote(second)} -ExpectedMergeTree {quote(tree)}" ) return ["powershell", "-NoProfile", "-Command", command] @@ -667,7 +954,8 @@ def select_candidate(candidates: list[Candidate], requested: int | None, input_f return None print(render_candidates(candidates)) selectable = [candidate for candidate in candidates - if candidate.state in {"Ready for resolution", "Independent"}] + if candidate.state in {"Ready for resolution", "Independent", + "Ready for current-main reconciliation"}] if requested is not None: matches = [candidate for candidate in candidates if int(candidate.pr["number"]) == requested] if len(matches) != 1: @@ -690,7 +978,11 @@ def select_candidate(candidates: list[Candidate], requested: int | None, input_f if chosen.state.startswith("Waiting on"): raise Refusal(f"PR #{chosen.pr['number']} is {chosen.state}. Resolve its predecessor first.") if chosen.state in {"Superseded", "Dependency ambiguous"}: - raise Refusal(f"PR #{chosen.pr['number']} is {chosen.state}; refusing to guess an integration order.") + detail = f" {chosen.refusal}" if chosen.refusal else "" + raise Refusal( + f"PR #{chosen.pr['number']} is {chosen.state}; refusing to guess an integration order." + f"{detail}" + ) return chosen @@ -699,14 +991,66 @@ def publication_phase(root: Path, runner: Runner, candidate: Candidate, input_fn repository = authenticate_downstream_repository(repository) if candidate.state.startswith("Waiting on") or candidate.state in {"Superseded", "Dependency ambiguous"}: raise Refusal(f"Candidate is {candidate.state}; semantic publication is not currently actionable.") - verify_local_descendant(runner, root, candidate) + resolution_first_parent = candidate.pr["head"]["sha"] native_conflict = bool(candidate.observation.get("conflict_paths")) + if candidate.current_main: + merge_head = git_text( + runner, root, "rev-parse", "-q", "--verify", "MERGE_HEAD", check=False + ) + head = git_text(runner, root, "rev-parse", "HEAD") + if merge_head == candidate.current_main: + resolution_first_parent = reconciliation_commit(runner, root, candidate) + if native_conflict: + begin_native_resolution(runner, root, candidate, resolution_first_parent) + output = root / ".logs" / "upstream-resolution" / f"pr-{candidate.pr['number']}" / "codex-prompt.md" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + prompt( + int(candidate.pr["number"]), candidate.pr, candidate.observation, + candidate.ci, "Current main reconciled; upstream resolution active", + ), + encoding="utf-8", newline="\n", + ) + print("Current-main reconciliation authenticated. Upstream semantic resolution is now active.") + print(f"Review `{output.relative_to(root).as_posix()}`, resolve upstream semantics, then rerun.") + return + if merge_head == candidate.observation.get("upstream_sha"): + parents = git_text(runner, root, "show", "-s", "--format=%P", head).split() + if parents != [candidate.pr["head"]["sha"], candidate.current_main]: + raise Refusal("Active upstream resolution is not based on exact reconciliation B=[C,M].") + resolution_first_parent = head + elif merge_head: + raise Refusal("Active merge does not match current-main or recorded-upstream identity.") + elif head == candidate.pr["head"]["sha"]: + reconciled = begin_main_reconciliation(runner, root, candidate) + if not reconciled: + print("Current-main reconciliation started. Review it before upstream resolution.") + return + resolution_first_parent = reconciled + if native_conflict: + begin_native_resolution(runner, root, candidate, resolution_first_parent) + print("Current main is already contained. Upstream semantic resolution is now active.") + return + else: + parents = git_text(runner, root, "show", "-s", "--format=%P", head).split() + if parents != [candidate.pr["head"]["sha"], candidate.current_main]: + raise Refusal("Local reconciliation commit does not have exact parents [C,M].") + resolution_first_parent = head + if native_conflict: + begin_native_resolution(runner, root, candidate, resolution_first_parent) + print("Authenticated reconciliation found. Upstream semantic resolution is now active.") + return + verify_local_descendant(runner, root, candidate) if native_conflict: - assert_native_merge_identity(runner, root, candidate) + assert_native_merge_identity(runner, root, candidate, resolution_first_parent) paths = resolution_paths(runner, root, candidate.pr["head"]["sha"]) attention = sorted(set(candidate.observation.get("review_paths") or []) | set(candidate.observation.get("conflict_paths") or [])) - validate_resolution_scope(paths, attention) + reconciliation_paths = validate_reconciliation_scope( + runner, root, candidate, resolution_first_parent + ) + upstream_paths = sorted(set(paths) - set(reconciliation_paths)) + validate_resolution_scope(upstream_paths, attention) filters = derive_filters(paths, attention) validate_filter_targets(root, filters) print("\nPublication plan") @@ -736,19 +1080,54 @@ def publication_phase(root: Path, runner: Runner, candidate: Candidate, input_fn raise Refusal(f"Candidate is now {current.state}; prepare-pr was not invoked.") if current.pr["head"]["sha"] != candidate.pr["head"]["sha"]: raise Refusal("Remote candidate head changed immediately before publication.") + if candidate.current_main: + keys = ( + "episode_id", "downstream_sha", "upstream_sha", "ownership_policy_version", + "existing_pr_number", "existing_pr_branch", "existing_pr_head_sha", + "candidate_sha", "candidate_tree", "comparison_baseline", + "classification_range_count", "review_paths", "conflict_paths", + ) + if (current.current_main != candidate.current_main + or any(current.current_observation.get(key) != candidate.current_observation.get(key) + for key in keys)): + raise Refusal("Fresh current-main reuse evidence changed after review began.") verify_local_descendant(runner, root, current) current_paths = resolution_paths(runner, root, current.pr["head"]["sha"]) - validate_resolution_scope(current_paths, attention) + current_reconciliation_paths = validate_reconciliation_scope( + runner, root, current, resolution_first_parent + ) + validate_resolution_scope( + sorted(set(current_paths) - set(current_reconciliation_paths)), attention + ) current_filters = derive_filters(current_paths, attention) validate_filter_targets(root, current_filters) if current_paths != paths or current_filters != filters: raise Refusal("Resolution scope or focused-test plan changed after approval; rerun and review it.") if native_conflict: authenticate_upstream_history(runner, root, current.observation["upstream_sha"]) - _, tree = commit_native_resolution(runner, root, current, current_paths) - first = current.pr["head"]["sha"] + _, tree = commit_native_resolution( + runner, root, current, current_paths, resolution_first_parent + ) + first = resolution_first_parent second = current.observation["upstream_sha"] - command = prepare_command(root, filters, first, second, tree) + command = prepare_command( + root, filters, first, second, tree, + remote_head=current.pr["head"]["sha"] if current.current_main else None, + original_candidate=current.observation.get("candidate_sha") if current.current_main else None, + current_main=current.current_main, + reconciliation_commit=first if current.current_main else None, + ) + elif current.current_main: + _, tree = commit_clean_reconciled_resolution( + runner, root, current, current_paths, resolution_first_parent + ) + command = prepare_command( + root, filters, resolution_first_parent, current.observation["upstream_sha"], tree, + remote_head=current.pr["head"]["sha"], + original_candidate=current.observation.get("candidate_sha"), + current_main=current.current_main, + reconciliation_commit=resolution_first_parent, + ) else: command = prepare_command(root, filters) runner.run(command, cwd=root) @@ -841,8 +1220,23 @@ def selected_output(number: int, root: Path, candidate: Candidate, runner: Runne run_url = observation.get("run_url") ci = candidate.ci checkout(runner, root, pr["head"]["ref"], pr["head"]["sha"]) + draft_extension_paths = validate_draft_extension_scope(runner, root, candidate) + if candidate.state == "Ready for current-main reconciliation": + reconciled = begin_main_reconciliation(runner, root, candidate) + if not reconciled: + content = reconciliation_prompt(candidate, runner, root, draft_extension_paths) + output = root / ".logs" / "upstream-resolution" / f"pr-{number}" / "codex-prompt.md" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(content, encoding="utf-8", newline="\n") + return ("\n".join([ + "Upstream resolution", "", f"PR: #{number}", + "Dependency: Ready for current-main reconciliation", "", + "Current-main reconciliation is active.", + f"Read `{output.relative_to(root).as_posix()}` and review it exactly.", + "Upstream semantic resolution has not begun.", + ]), output) if observation.get("conflict_paths"): - begin_native_resolution(runner, root, candidate) + begin_native_resolution(runner, root, candidate, reconciled if candidate.current_main else None) content = prompt(number, pr, observation, ci, candidate.state) output = root / ".logs" / "upstream-resolution" / f"pr-{number}" / "codex-prompt.md" output.parent.mkdir(parents=True, exist_ok=True) @@ -892,7 +1286,9 @@ def main() -> int: root = Path(__file__).resolve().parent.parent try: runner = Runner() - branch, dirty = assert_preflight(runner, root, allow_dirty=True) + branch, dirty = assert_preflight( + runner, root, allow_dirty=True, allow_resolution_merge=True + ) repository = authenticated_origin(runner, root) candidates = classify_dependencies( runner, root, open_candidates(runner, root, repository), repository diff --git a/scripts/test_hosted_upstream.py b/scripts/test_hosted_upstream.py index 9b6dd424b..2e5710505 100644 --- a/scripts/test_hosted_upstream.py +++ b/scripts/test_hosted_upstream.py @@ -214,7 +214,15 @@ def test_textual_conflict_creates_deterministic_blocked_workspace(self): self.github.records = [self.pr(o, draft=True)] retry, existing = self.observe() self.assertEqual("existing_draft_pr", existing["outcome"]) - sync.publish(retry, self.github, existing, existing["upstream_sha"], existing["downstream_sha"]) + self.assertEqual(123, existing["existing_pr_number"]) + self.assertEqual(existing["branch"], existing["existing_pr_branch"]) + self.assertEqual(existing["candidate_sha"], existing["existing_pr_head_sha"]) + sync.publish( + retry, self.github, existing, existing["upstream_sha"], existing["downstream_sha"], + expected_existing_pr=existing["existing_pr_number"], + expected_existing_branch=existing["existing_pr_branch"], + expected_existing_head=existing["existing_pr_head_sha"], + ) self.assertFalse(retry.pushes) def test_invalid_fetch_or_push_identity(self): @@ -361,7 +369,12 @@ def test_existing_open_pr_reused_without_writes(self): self.retain_pr(git, o) retry, result = self.observe() self.assertEqual(result["outcome"], "existing_pr") - sync.publish(retry, self.github, result, result["upstream_sha"], result["downstream_sha"]) + sync.publish( + retry, self.github, result, result["upstream_sha"], result["downstream_sha"], + expected_existing_pr=result["existing_pr_number"], + expected_existing_branch=result["existing_pr_branch"], + expected_existing_head=result["existing_pr_head_sha"], + ) self.assertFalse(retry.pushes or self.github.created) summary = sync.upstream_summary(result, publication=True) self.assertIn("### Candidate ready", summary) @@ -820,8 +833,24 @@ def test_three_observations_reuse_one_native_draft_pr(self): observed_at=f"2026-09-10T{hour:02}:00:00+00:00", run_url=f"https://github.com/constbogdan/Wholphin/actions/runs/{run}") self.assertEqual("existing_draft_pr", observation["outcome"]) - sync.publish(retry, self.github, observation, - observation["upstream_sha"], observation["downstream_sha"]) + self.assertEqual(123, observation["existing_pr_number"]) + self.assertEqual(first["branch"], observation["existing_pr_branch"]) + self.assertEqual(first["candidate_sha"], observation["existing_pr_head_sha"]) + with self.assertRaisesRegex(sync.Blocked, "Existing Draft state changed"): + sync.publish( + retry, self.github, observation, + observation["upstream_sha"], observation["downstream_sha"], + expected_existing_pr=observation["existing_pr_number"], + expected_existing_branch=observation["existing_pr_branch"], + expected_existing_head="0" * 40, + ) + sync.publish( + retry, self.github, observation, + observation["upstream_sha"], observation["downstream_sha"], + expected_existing_pr=observation["existing_pr_number"], + expected_existing_branch=observation["existing_pr_branch"], + expected_existing_head=observation["existing_pr_head_sha"], + ) self.assertFalse(retry.pushes) self.assertEqual(1, len(self.github.created)) @@ -840,8 +869,16 @@ def test_unrelated_downstream_movement_reuses_attention_episode(self): self.assertEqual(first_episode, observation["episode_id"]) self.assertEqual("existing_draft_pr", observation["outcome"]) self.assertEqual(first_branch, observation["existing_branch"]) - sync.publish(retry, self.github, observation, - observation["upstream_sha"], observation["downstream_sha"]) + self.assertEqual(123, observation["existing_pr_number"]) + self.assertEqual(first_branch, observation["existing_pr_branch"]) + self.assertEqual(first["candidate_sha"], observation["existing_pr_head_sha"]) + sync.publish( + retry, self.github, observation, + observation["upstream_sha"], observation["downstream_sha"], + expected_existing_pr=observation["existing_pr_number"], + expected_existing_branch=observation["existing_pr_branch"], + expected_existing_head=observation["existing_pr_head_sha"], + ) self.assertEqual(1, len(self.github.created)) def test_new_upstream_same_area_updates_episode_evidence_without_duplicate(self): @@ -856,8 +893,13 @@ def test_new_upstream_same_area_updates_episode_evidence_without_duplicate(self) self.assertNotEqual(first_upstream, observation["upstream_sha"]) self.assertEqual(first["episode_id"], observation["episode_id"]) self.assertEqual("existing_draft_pr", observation["outcome"]) - sync.publish(retry, self.github, observation, - observation["upstream_sha"], observation["downstream_sha"]) + sync.publish( + retry, self.github, observation, + observation["upstream_sha"], observation["downstream_sha"], + expected_existing_pr=observation["existing_pr_number"], + expected_existing_branch=observation["existing_pr_branch"], + expected_existing_head=observation["existing_pr_head_sha"], + ) self.assertEqual(1, len(self.github.created)) def test_quiet_pr_and_rich_summary_split_operator_navigation_from_provenance(self): path = "app/src/SeriesViewModel.kt" diff --git a/scripts/test_prepare_pr.py b/scripts/test_prepare_pr.py index 36fc4022b..cceb8de97 100644 --- a/scripts/test_prepare_pr.py +++ b/scripts/test_prepare_pr.py @@ -509,6 +509,36 @@ def create_native_upstream_merge(self): self.git("branch", "-m", "chore/sync-upstream-fixture") return first, upstream, merge, resolved_tree + def create_reconciled_upstream_merge(self): + remote_head = self.commit_current_worktree("fix: reviewed Draft head") + base = self.git("rev-parse", "origin/main").stdout.strip() + base_tree = self.git("rev-parse", "origin/main^{tree}").stdout.strip() + current_main = subprocess.run( + [GIT, "commit-tree", base_tree, "-p", base], cwd=self.root, + input="current main fixture\n", check=True, capture_output=True, text=True, + env=self.git_environment, + ).stdout.strip() + self.git("push", "origin", f"{current_main}:refs/heads/main") + reconciliation_tree = self.git("rev-parse", remote_head + "^{tree}").stdout.strip() + reconciliation = subprocess.run( + [GIT, "commit-tree", reconciliation_tree, "-p", remote_head, "-p", current_main], + cwd=self.root, input="reconcile fixture\n", check=True, capture_output=True, text=True, + env=self.git_environment, + ).stdout.strip() + upstream = subprocess.run( + [GIT, "commit-tree", base_tree, "-p", base], cwd=self.root, + input="upstream fixture\n", check=True, capture_output=True, text=True, + env=self.git_environment, + ).stdout.strip() + final = subprocess.run( + [GIT, "commit-tree", reconciliation_tree, "-p", reconciliation, "-p", upstream], + cwd=self.root, input="resolved upstream fixture\n", check=True, + capture_output=True, text=True, env=self.git_environment, + ).stdout.strip() + self.git("update-ref", "HEAD", final) + self.git("branch", "-m", "chore/sync-upstream-reconciled-fixture") + return remote_head, current_main, reconciliation, upstream, final, reconciliation_tree + def test_guided_success_is_concise_with_readable_link_fallbacks(self): env = { **self.fake_publish_env(), @@ -869,6 +899,57 @@ def test_upstream_resolution_draft_remains_human_controlled(self): self.git("merge-base", "--is-ancestor", first, published_head).returncode, ) + def test_reconciled_upstream_publication_authenticates_both_merge_layers(self): + remote, current_main, reconciliation, upstream, final, tree = ( + self.create_reconciled_upstream_merge() + ) + identity = ( + "-PreserveReconciledUpstreamMerge", + "-ExpectedRemoteDraftHead", remote, + "-ExpectedOriginalCandidate", remote, + "-ExpectedCurrentMain", current_main, + "-ExpectedReconciliationCommit", reconciliation, + "-ExpectedMergeFirstParent", reconciliation, + "-ExpectedMergeSecondParent", upstream, + "-ExpectedMergeTree", tree, + ) + result = self.prepare( + "Guided", *identity, "-TestFilter", "*UpstreamFixtureTest*", + env=self.fake_publish_env( + remote_sha=remote, pr_mode="upstream_draft", list_head_before=remote, + ), check=False, + ) + self.assertEqual(0, result.returncode, normalized_native_output(result)) + self.assertEqual(final, self.remote_head()) + self.assertIn("Auto-merge: EXCLUDED", result.stdout) + self.assertFalse(any("--force" in args for args in self.fake_trace("git"))) + + def test_reconciled_upstream_publication_refuses_main_or_remote_drift(self): + remote, current_main, reconciliation, upstream, _, tree = ( + self.create_reconciled_upstream_merge() + ) + common = ( + "-PreserveReconciledUpstreamMerge", + "-ExpectedRemoteDraftHead", remote, + "-ExpectedOriginalCandidate", remote, + "-ExpectedCurrentMain", current_main, + "-ExpectedReconciliationCommit", reconciliation, + "-ExpectedMergeFirstParent", reconciliation, + "-ExpectedMergeSecondParent", upstream, + "-ExpectedMergeTree", tree, + "-TestFilter", "*UpstreamFixtureTest*", + ) + drifted = list(common) + drifted[drifted.index("-ExpectedRemoteDraftHead") + 1] = "9" * 40 + result = self.prepare( + "Guided", *drifted, + env=self.fake_publish_env( + remote_sha=remote, pr_mode="upstream_draft", list_head_before="9" * 40, + ), check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("Draft branch moved", normalized_native_output(result)) + def test_wrong_repository_base_or_head_identity_refuses(self): reviewed_head = self.commit_current_worktree() self.prepare("Audit") diff --git a/scripts/test_resolve_upstream.py b/scripts/test_resolve_upstream.py index d27df421a..e81ccf53b 100644 --- a/scripts/test_resolve_upstream.py +++ b/scripts/test_resolve_upstream.py @@ -108,6 +108,8 @@ def _result(self, args): return resolve.Result("?? local.txt\n" if self.dirty else "", "", 0) if args[:3] == ["git", "branch", "--show-current"]: return resolve.Result(self.current_branch + "\n", "", 0) + if args[:5] == ["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"]: + return resolve.Result("", "", 1) if args[:3] == ["gh", "auth", "status"]: return resolve.Result("authenticated", "", 0) if args[:3] == ["gh", "api", f"repos/{self.repository}/pulls/33"]: @@ -277,6 +279,90 @@ def git(*args, check=True): check=False).returncode) self.assertEqual("resolved downstream + upstream", git("show", commit + ":source.kt").stdout.strip()) + def test_reused_draft_reconciliation_produces_exact_b_and_r_topology(self): + root = self.root() + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1") + + def git(*args, check=True): + return subprocess.run(["git", *args], cwd=root, env=env, text=True, + capture_output=True, check=check) + + git("init", "-q", "-b", "main") + git("config", "user.name", "Fixture") + git("config", "user.email", "fixture@example.invalid") + (root / "source.kt").write_text("base\n", encoding="utf-8", newline="\n") + git("add", "source.kt") + git("commit", "-q", "-m", "D") + downstream = git("rev-parse", "HEAD").stdout.strip() + + git("switch", "-q", "-c", "upstream") + (root / "source.kt").write_text("upstream\n", encoding="utf-8", newline="\n") + git("commit", "-qam", "U") + upstream = git("rev-parse", "HEAD").stdout.strip() + upstream_bare = root / "upstream.git" + subprocess.run(["git", "init", "-q", "--bare", str(upstream_bare)], env=env, check=True) + git("remote", "add", "upstream", str(upstream_bare)) + git("push", "-q", "upstream", f"{upstream}:refs/heads/main") + + git("switch", "-q", "--detach", downstream) + context_path = root / ".upstream-sync" / "blocked-context.json" + context_path.parent.mkdir() + context_path.write_text(json.dumps({ + "schemaVersion": 1, "upstream": upstream, "downstream": downstream, + "policyVersion": 1, "conflicts": ["source.kt"], + }), encoding="utf-8") + git("add", ".upstream-sync/blocked-context.json") + git("commit", "-q", "-m", "A") + candidate_sha = git("rev-parse", "HEAD").stdout.strip() + branch = f"chore/sync-upstream-{upstream}-{downstream}" + git("switch", "-q", "-c", branch) + + bare = root / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", str(bare)], env=env, check=True) + git("remote", "add", "origin", str(bare)) + git("push", "-q", "origin", f"{candidate_sha}:refs/heads/{branch}") + git("switch", "-q", "--detach", downstream) + (root / "main.txt").write_text("current main\n", encoding="utf-8", newline="\n") + git("add", "main.txt") + git("commit", "-q", "-m", "M") + current_main = git("rev-parse", "HEAD").stdout.strip() + git("push", "-q", "origin", f"{current_main}:refs/heads/main") + git("switch", "-q", branch) + + observation = { + "candidate_sha": candidate_sha, + "candidate_tree": git("rev-parse", candidate_sha + "^{tree}").stdout.strip(), + "upstream_sha": upstream, "downstream_sha": downstream, + "ownership_policy_version": 1, "conflict_paths": ["source.kt"], + "review_paths": ["source.kt"], + } + candidate = resolve.Candidate( + {"number": 33, "head": {"ref": branch, "sha": candidate_sha}}, + observation, {}, current_main=current_main, + ) + runner = resolve.Runner() + self.assertEqual("", resolve.begin_main_reconciliation(runner, root, candidate)) + self.assertEqual(current_main, git("rev-parse", "MERGE_HEAD").stdout.strip()) + b_commit = resolve.reconciliation_commit(runner, root, candidate) + self.assertEqual( + [candidate_sha, current_main], + git("show", "-s", "--format=%P", b_commit).stdout.split(), + ) + resolve.begin_native_resolution(runner, root, candidate, b_commit) + (root / "source.kt").write_text( + "resolved downstream + upstream\n", encoding="utf-8", newline="\n" + ) + git("add", "source.kt") + r_commit, reviewed_tree = resolve.commit_native_resolution( + runner, root, candidate, ["source.kt", ".upstream-sync/blocked-context.json"], b_commit + ) + self.assertEqual([b_commit, upstream], git("show", "-s", "--format=%P", r_commit).stdout.split()) + self.assertEqual(reviewed_tree, git("rev-parse", r_commit + "^{tree}").stdout.strip()) + for ancestor in (candidate_sha, current_main, upstream): + self.assertEqual(0, git("merge-base", "--is-ancestor", ancestor, r_commit, + check=False).returncode) + def test_upstream_rewrite_and_incomplete_context_fail_before_merge(self): with self.assertRaisesRegex(resolve.Refusal, "no longer belongs"): self.execute(FakeRunner(upstream_rewritten=True)) @@ -474,6 +560,111 @@ def test_human_edited_draft_head_is_accepted_only_as_proven_descendant(self): runner.compare_map[f"{CANDIDATE}...{'8' * 40}"] = "ahead" resolve.validate_observation(observation, pr, EPISODE, runner=runner, root=self.root()) + def test_descendant_draft_scope_must_remain_explainable(self): + class Descendant(FakeRunner): + def __init__(self, path): + super().__init__() + self.path = path + + def pr(self): + value = super().pr() + value["head"]["sha"] = "8" * 40 + return value + + def _result(self, args): + if args[:5] == ["git", "diff", "--no-renames", "--name-only", CANDIDATE]: + return resolve.Result(self.path + "\n", "", 0) + return super()._result(args) + + allowed = Descendant("app/SeriesViewModel.kt") + candidate = resolve.Candidate(allowed.pr(), allowed.observation(), {}) + self.assertEqual( + ["app/SeriesViewModel.kt"], + resolve.validate_draft_extension_scope(allowed, self.root(), candidate), + ) + refused = Descendant("unrelated.txt") + with self.assertRaisesRegex(resolve.Refusal, "unexplained paths"): + resolve.validate_draft_extension_scope( + refused, self.root(), + resolve.Candidate(refused.pr(), refused.observation(), {}), + ) + + def current_reuse_runner(self, *, artifacts=None, runs=None): + current_main = "6" * 40 + current_upstream = "7" * 40 + deterministic = "8" * 40 + base = FakeRunner() + evidence = base.observation() + evidence.update( + schema_version=2, + branch=f"chore/sync-upstream-{current_upstream}-{current_main}", + candidate_sha=deterministic, + candidate_tree="5" * 40, + upstream_sha=current_upstream, + downstream_sha=current_main, + run_id="900", + run_attempt="1", + outcome="existing_draft_pr", + existing_pr_number=33, + existing_pr_branch=BRANCH, + existing_pr_head_sha=CANDIDATE, + ) + artifacts = {900: evidence} if artifacts is None else artifacts + runs = ([{ + "id": run_id, "run_attempt": 1, "head_sha": current_main, + "head_branch": "main", "path": resolve.UPSTREAM_WORKFLOW_PATH, + "conclusion": "success", "event": "schedule", + } for run_id in artifacts] if runs is None else runs) + + class CurrentReuse(FakeRunner): + def _result(self, args): + if args[:3] == [ + "gh", "api", + f"repos/{resolve.REPOSITORY}/actions/workflows/upstream-sync.yml/runs?branch=main&status=success&per_page=100"]: + return resolve.Result(json.dumps({"workflow_runs": runs}), "", 0) + if args[:3] == ["gh", "run", "download"] and int(args[3]) in artifacts: + destination = Path(args[args.index("--dir") + 1]) + (destination / "outcome.json").write_text( + json.dumps(artifacts[int(args[3])]), encoding="utf-8" + ) + return resolve.Result("", "", 0) + return super()._result(args) + + return CurrentReuse(), current_main, evidence + + def test_fresh_exact_main_same_episode_reuse_is_fully_authenticated(self): + runner, current_main, expected = self.current_reuse_runner() + actual = resolve.load_current_reuse_observation( + runner, self.root(), runner.pr(), runner.observation(), current_main + ) + self.assertEqual(expected, actual) + self.assertTrue(any("actions/workflows/upstream-sync.yml/runs" in " ".join(call) + for call in runner.calls)) + + def test_missing_or_expired_fresh_reuse_evidence_refuses(self): + runner, current_main, _ = self.current_reuse_runner(artifacts={}, runs=[]) + with self.assertRaisesRegex(resolve.Refusal, "Run the normal Upstream Synchronization"): + resolve.load_current_reuse_observation( + runner, self.root(), runner.pr(), runner.observation(), current_main + ) + + def test_fresh_reuse_binding_mismatch_and_ambiguity_refuse(self): + runner, current_main, evidence = self.current_reuse_runner() + wrong = dict(evidence, existing_pr_head_sha="9" * 40) + wrong_runner, _, _ = self.current_reuse_runner(artifacts={900: wrong}) + with self.assertRaisesRegex(resolve.Refusal, "No fresh authenticated"): + resolve.load_current_reuse_observation( + wrong_runner, self.root(), wrong_runner.pr(), wrong_runner.observation(), current_main + ) + + disagreeing = dict(evidence, run_id="901", upstream_sha="4" * 40, + branch=f"chore/sync-upstream-{'4' * 40}-{current_main}") + ambiguous, _, _ = self.current_reuse_runner(artifacts={900: evidence, 901: disagreeing}) + with self.assertRaisesRegex(resolve.Refusal, "disagree"): + resolve.load_current_reuse_observation( + ambiguous, self.root(), ambiguous.pr(), ambiguous.observation(), current_main + ) + def test_ci_success_renders(self): _, summary, _ = self.execute(FakeRunner(ci_bucket="pass")) self.assertIn("CI: PASSED", summary)