diff --git a/.github/workflows/deploy-launchplane.yml b/.github/workflows/deploy-launchplane.yml index 94f5c0b..e7070a5 100644 --- a/.github/workflows/deploy-launchplane.yml +++ b/.github/workflows/deploy-launchplane.yml @@ -360,7 +360,7 @@ jobs: continue fi health_response_file="$(mktemp)" - health_status_code="$(curl -sS -o "$health_response_file" -w '%{http_code}' "$health_url" 2>/dev/null || true)" + health_status_code="$(curl -sSL -o "$health_response_file" -w '%{http_code}' "$health_url" 2>/dev/null || true)" health_status_value="$(jq -r '.status // empty' "$health_response_file" 2>/dev/null || true)" if [ -z "$health_report" ]; then health_report="${health_url}=${health_status_code:-curl_failed}:${health_status_value:-unknown}" @@ -545,7 +545,7 @@ jobs: continue fi health_response_file="$(mktemp)" - health_status_code="$(curl -sS -o "$health_response_file" -w '%{http_code}' "$health_url" 2>/dev/null || true)" + health_status_code="$(curl -sSL -o "$health_response_file" -w '%{http_code}' "$health_url" 2>/dev/null || true)" health_status_value="$(jq -r '.status // empty' "$health_response_file" 2>/dev/null || true)" if [ -z "$health_report" ]; then health_report="${health_url}=${health_status_code:-curl_failed}:${health_status_value:-unknown}" diff --git a/.github/workflows/merge-train-runner.yml b/.github/workflows/merge-train-runner.yml index 7939c93..503acf8 100644 --- a/.github/workflows/merge-train-runner.yml +++ b/.github/workflows/merge-train-runner.yml @@ -304,6 +304,31 @@ jobs: echo "- Next action: ${next_action}" } >> "$GITHUB_STEP_SUMMARY" + - name: Validate merge-train phase selection + if: steps.admission.outputs.admission_status == 'admitted' + shell: bash + run: | + set -euo pipefail + selected_modes=() + + if [ "${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" != "none" ]; then + selected_modes+=("batch-candidate:${MERGE_TRAIN_BATCH_CANDIDATE_MODE}") + fi + + if [ "${MERGE_TRAIN_BATCH_LANDING_MODE}" != "none" ]; then + selected_modes+=("batch-landing:${MERGE_TRAIN_BATCH_LANDING_MODE}") + fi + + if [ "${MERGE_TRAIN_STACK_COLLAPSE_MODE}" != "none" ]; then + selected_modes+=("stack-collapse:${MERGE_TRAIN_STACK_COLLAPSE_MODE}") + fi + + if [ "${#selected_modes[@]}" -gt 1 ]; then + echo "Only one merge-train phase may run per workflow dispatch." >&2 + printf 'Selected modes: %s\n' "${selected_modes[*]}" >&2 + exit 1 + fi + - name: Run one batch-candidate phase if: >- steps.admission.outputs.admission_status == 'admitted' && @@ -404,11 +429,6 @@ jobs: SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }} run: | set -euo pipefail - if [ "${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" != "none" ] || \ - [ "${MERGE_TRAIN_BATCH_LANDING_MODE}" != "none" ]; then - echo "Only one batch or stack-collapse mode may run." >&2 - exit 1 - fi case "$MERGE_TRAIN_STACK_COLLAPSE_MODE" in execute|admit) ;; *) @@ -500,10 +520,6 @@ jobs: SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }} run: | set -euo pipefail - if [ "${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" != "none" ]; then - echo "Only one batch mode may run per workflow dispatch." >&2 - exit 1 - fi case "$MERGE_TRAIN_BATCH_LANDING_MODE" in plan|land) ;; *) diff --git a/control_plane/contracts/merge_train_policy.py b/control_plane/contracts/merge_train_policy.py index 0533908..9b5ce87 100644 --- a/control_plane/contracts/merge_train_policy.py +++ b/control_plane/contracts/merge_train_policy.py @@ -81,6 +81,7 @@ class MergeTrainRepositoryPolicy(BaseModel): base_branch: str enqueue_label: str blocked_label: str + stack_child_disposition_label: str = "" merge_method: MergeTrainMergeMethod failure_policy: MergeTrainFailurePolicy enqueue: MergeTrainEnqueuePolicy @@ -104,8 +105,16 @@ def _validate_repository_policy(self) -> "MergeTrainRepositoryPolicy": self.blocked_label = _normalize_required_value( self.blocked_label, "merge train policy requires blocked_label" ) + self.stack_child_disposition_label = self.stack_child_disposition_label.strip() if self.enqueue_label == self.blocked_label: raise ValueError("merge train enqueue_label and blocked_label must differ") + if self.stack_child_disposition_label in { + self.enqueue_label, + self.blocked_label, + }: + raise ValueError( + "merge train stack_child_disposition_label must differ from enqueue_label and blocked_label" + ) return self @property @@ -201,9 +210,13 @@ def load_merge_train_policy(policy_file: Path) -> MergeTrainPolicy: def merge_train_policy_sha256(policy: MergeTrainPolicy) -> str: - encoded = json.dumps( - policy.model_dump(mode="json"), sort_keys=True, separators=(",", ":") - ).encode("utf-8") + policy_payload = policy.model_dump(mode="json") + for repository_policy in policy_payload.get("policies", ()): + if isinstance(repository_policy, dict) and not repository_policy.get( + "stack_child_disposition_label" + ): + repository_policy.pop("stack_child_disposition_label", None) + encoded = json.dumps(policy_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() diff --git a/control_plane/service.py b/control_plane/service.py index e0385fd..7f105a5 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -2655,9 +2655,7 @@ def _validate_stack_collapse_record_for_landing( ) if root_entry is None: raise ValueError("merge train stack collapse root PR is missing from landing plan") - if root_entry.expected_head_sha != _stack_collapse_expected_root_head_sha( - stack_collapse_plan - ): + if root_entry.expected_head_sha != _stack_collapse_expected_root_head_sha(stack_collapse_plan): raise ValueError("merge train stack collapse root PR head no longer matches") @@ -7129,11 +7127,9 @@ def product_action_allowed( "message": "Configured merge train GitHub token is not available.", }, }, - ) + ) batch_store = _merge_train_batch_candidate_record_store(record_store) - stack_collapse_store = _merge_train_stack_collapse_plan_record_store( - record_store - ) + stack_collapse_store = _merge_train_stack_collapse_plan_record_store(record_store) transport = UrllibMergeTrainGitHubTransport( token=token, api_base_url=batch_request.github_api_base_url, @@ -7161,7 +7157,10 @@ def product_action_allowed( ) else: stack_discovery = None - if stack_discovery is not None and stack_discovery.status == "ready_for_collapse": + if ( + stack_discovery is not None + and stack_discovery.status == "ready_for_collapse" + ): stack_collapse_plan = build_merge_train_stack_collapse_plan( discovery_result=stack_discovery, policy_key=dry_run_result.policy_key, @@ -7337,6 +7336,10 @@ def product_action_allowed( landing_plan=landing_record.landing_plan, policy_sha256=policy_record.policy_sha256, ) + if not repository_policy.stack_child_disposition_label: + raise ValueError( + "merge train stack child disposition requires stack_child_disposition_label policy" + ) transport = UrllibMergeTrainGitHubTransport( token=token, api_base_url=landing_request.github_api_base_url, @@ -7345,6 +7348,12 @@ def product_action_allowed( landing_plan = github_client.land_batch_candidate( landing_plan=landing_record.landing_plan ) + landing_record = build_merge_train_batch_landing_plan_record( + landing_plan=landing_plan, + source=f"service:{landing_request.mode}:{request_trace_id}", + updated_at=recorded_at, + ) + landing_store.write_merge_train_batch_landing_plan_record(landing_record) if collapse_existing_record is not None: root_entry = next( ( @@ -7364,7 +7373,7 @@ def product_action_allowed( plan=collapse_existing_record.plan, disposition_client=github_client, root_merge_commit_sha=root_entry.merge_commit_sha, - label="stack-landed", + label=repository_policy.stack_child_disposition_label, updated_at=recorded_at, ) ) @@ -7373,15 +7382,14 @@ def product_action_allowed( source=f"service:child-disposition:{request_trace_id}", updated_at=recorded_at, ) - collapse_store.write_merge_train_stack_collapse_plan_record( - collapse_record - ) - landing_record = build_merge_train_batch_landing_plan_record( - landing_plan=landing_plan, - source=f"service:{landing_request.mode}:{request_trace_id}", - updated_at=recorded_at, - ) - landing_store.write_merge_train_batch_landing_plan_record(landing_record) + collapse_store.write_merge_train_stack_collapse_plan_record(collapse_record) + if landing_request.mode == "plan": + landing_record = build_merge_train_batch_landing_plan_record( + landing_plan=landing_plan, + source=f"service:{landing_request.mode}:{request_trace_id}", + updated_at=recorded_at, + ) + landing_store.write_merge_train_batch_landing_plan_record(landing_record) result = { "merge_train_batch_landing_plan_record_id": landing_record.record_id, "repository": landing_plan.repository, @@ -7393,10 +7401,11 @@ def product_action_allowed( if collapse_record is None or reconciled_collapse_plan is None: raise ValueError("merge train stack child disposition record missing") result["merge_train_stack_collapse_plan_record_id"] = collapse_record.record_id - result["stack_collapse_plan"] = reconciled_collapse_plan.model_dump( - mode="json" - ) - driver_result = {"mode": landing_request.mode, "landing_plan": result["landing_plan"]} + result["stack_collapse_plan"] = reconciled_collapse_plan.model_dump(mode="json") + driver_result = { + "mode": landing_request.mode, + "landing_plan": result["landing_plan"], + } if "stack_collapse_plan" in result: driver_result["stack_collapse_plan"] = result["stack_collapse_plan"] elif path == _MERGE_TRAIN_STACK_COLLAPSE_RUN_ONCE_ROUTE: @@ -7508,8 +7517,7 @@ def product_action_allowed( ( pull_request for pull_request in snapshot.pull_requests - if pull_request.number - == stack_collapse_plan.root_pull_request_number + if pull_request.number == stack_collapse_plan.root_pull_request_number ), None, ) @@ -7521,11 +7529,7 @@ def product_action_allowed( raise ValueError( "merge train stack collapse root PR head no longer matches" ) - snapshot = snapshot.model_copy( - update={ - "pull_requests": (root_pull_request,) - } - ) + snapshot = snapshot.model_copy(update={"pull_requests": (root_pull_request,)}) dry_run_result = build_merge_train_dry_run_result( policy=policy, snapshot=snapshot ) diff --git a/docs/merge-train-policy.md b/docs/merge-train-policy.md index 53228ac..718b2d1 100644 --- a/docs/merge-train-policy.md +++ b/docs/merge-train-policy.md @@ -39,6 +39,10 @@ Each repository policy contains: - `base_branch`: Branch the train protects and merges into. - `enqueue_label`: Label required before a pull request can enter the train. - `blocked_label`: Label Launchplane applies when a queued pull request blocks. +- `stack_child_disposition_label`: Label Launchplane applies to child PRs after + a same-repository stack has landed through its root PR. It must be configured + for stack child disposition and must differ from `enqueue_label` and + `blocked_label`. - `merge_method`: GitHub merge strategy, one of `merge`, `squash`, or `rebase`. - `failure_policy`: Whether Launchplane pauses the whole train or continues after marking the blocked pull request. @@ -158,6 +162,7 @@ repository = "cbusillo/sellyouroutboard" base_branch = "main" enqueue_label = "ready-to-merge" blocked_label = "merge-blocked" +stack_child_disposition_label = "stack-landed" merge_method = "merge" failure_policy = "pause_train" @@ -182,6 +187,7 @@ repository = "cbusillo/codex-skills" base_branch = "main" enqueue_label = "ready-to-merge" blocked_label = "merge-blocked" +stack_child_disposition_label = "stack-landed" merge_method = "merge" failure_policy = "pause_train" diff --git a/docs/service-boundary.md b/docs/service-boundary.md index ca62231..dc41138 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -261,6 +261,9 @@ variables, not service code. Manual dispatch defaults to dry-run mode; scheduled runs also dry-run unless the repository variable `LAUNCHPLANE_MERGE_TRAIN_MUTATE` is set to `true`. This keeps activation explicit after setting `LAUNCHPLANE_MERGE_TRAIN_REPOSITORY`. +Workflow dispatches may select at most one non-`none` phase input across +batch-candidate, batch-landing, and stack-collapse modes; the runner validates +that exclusivity before any phase step mutates state. `GET /v1/repo-product-mapping` returns the repository ownership/awareness read model used by work graph and future agent context. The route requires diff --git a/frontend/src/useProductSelection.ts b/frontend/src/useProductSelection.ts index f84a3fd..8bc3248 100644 --- a/frontend/src/useProductSelection.ts +++ b/frontend/src/useProductSelection.ts @@ -62,10 +62,17 @@ export function choiceKey(choice: DriverChoice): string { function choiceDisplayKey(choice: DriverChoice): string { const normalizedLabel = choice.label.trim().toLowerCase(); - return `${choice.driverId}:${normalizedLabel}`; + return normalizedLabel; } -function labelForDriverContext(driver: DriverDescriptor, context: string): string { +function hasProductBacking(choice: DriverChoice): boolean { + return Boolean(choice.repository?.trim()); +} + +function labelForDriverContext( + driver: DriverDescriptor, + context: string, +): string { if (driver.driver_id === "odoo") { if (context === "cm") { return "Odoo CM"; @@ -164,17 +171,15 @@ export function choiceFromProductOverview( }; } -export function useProductSelection( - { - drivers, - productProfiles, - productOverviews, - }: { - drivers: DriverDescriptor[]; - productProfiles: ProductProfileRecord[]; - productOverviews: ProductSiteOverview[]; - }, -) { +export function useProductSelection({ + drivers, + productProfiles, + productOverviews, +}: { + drivers: DriverDescriptor[]; + productProfiles: ProductProfileRecord[]; + productOverviews: ProductSiteOverview[]; +}) { const [selected, setSelected] = useState(DEFAULT_CHOICES[0]); const choices = useMemo(() => { const driverChoices: DriverChoice[] = drivers.flatMap((driver) => { @@ -205,17 +210,19 @@ export function useProductSelection( ...driverChoices, ...DEFAULT_CHOICES, ]; - const productDisplayKeys = new Set( - [...overviewChoices, ...profileChoices, ...DEFAULT_CHOICES].map( - choiceDisplayKey, - ), + const productBackedDisplayKeys = new Set( + [...overviewChoices, ...profileChoices].map(choiceDisplayKey), ); const seen = new Set(); return merged.filter((choice) => { const displayKey = choiceDisplayKey(choice); - const key = productDisplayKeys.has(displayKey) - ? displayKey - : choiceKey(choice); + if ( + productBackedDisplayKeys.has(displayKey) && + !hasProductBacking(choice) + ) { + return false; + } + const key = choiceKey(choice); if (seen.has(key)) { return false; } diff --git a/tests/merge_train_policy_fixtures.py b/tests/merge_train_policy_fixtures.py index 4acdd2c..bb850a6 100644 --- a/tests/merge_train_policy_fixtures.py +++ b/tests/merge_train_policy_fixtures.py @@ -46,6 +46,7 @@ def _policy_table(repository: str) -> str: base_branch = "main" enqueue_label = "ready-to-merge" blocked_label = "merge-blocked" +stack_child_disposition_label = "stack-landed" merge_method = "merge" failure_policy = "pause_train" diff --git a/tests/test_merge_train_policy.py b/tests/test_merge_train_policy.py index da95870..0a5976b 100644 --- a/tests/test_merge_train_policy.py +++ b/tests/test_merge_train_policy.py @@ -33,6 +33,7 @@ def test_policy_can_include_multiple_repository_branch_entries(self) -> None: ) self.assertEqual(codex_skills_policy.enqueue_label, "ready-to-merge") self.assertEqual(codex_skills_policy.blocked_label, "merge-blocked") + self.assertEqual(codex_skills_policy.stack_child_disposition_label, "stack-landed") self.assertEqual(codex_skills_policy.merge_method, "merge") self.assertEqual(codex_skills_policy.github_token.env_var, "GH_TOKEN") self.assertEqual(codex_skills_policy.service_authz.action, "merge_train.run_once") @@ -57,6 +58,55 @@ def test_policy_record_validates_digest(self) -> None: f"merge-train-policy-20260513T210000Z-{policy.policy_sha256[:12]}", ) + def test_policy_record_digest_ignores_missing_optional_stack_child_label( + self, + ) -> None: + policy = parse_merge_train_policy_toml( + textwrap.dedent( + """ + schema_version = 1 + + [[policies]] + repository = "example/app" + base_branch = "main" + enqueue_label = "ready-to-merge" + blocked_label = "merge-blocked" + merge_method = "merge" + failure_policy = "pause_train" + [policies.enqueue] + label_required = true + allowed_actor_roles = ["repo_owner"] + [policies.merge_identity] + kind = "github_app" + name = "launchplane" + """ + ).strip() + ) + explicit_empty_policy = parse_merge_train_policy_toml( + textwrap.dedent( + """ + schema_version = 1 + + [[policies]] + repository = "example/app" + base_branch = "main" + enqueue_label = "ready-to-merge" + blocked_label = "merge-blocked" + stack_child_disposition_label = "" + merge_method = "merge" + failure_policy = "pause_train" + [policies.enqueue] + label_required = true + allowed_actor_roles = ["repo_owner"] + [policies.merge_identity] + kind = "github_app" + name = "launchplane" + """ + ).strip() + ) + + self.assertEqual(policy.policy_sha256, explicit_empty_policy.policy_sha256) + def test_resolve_merge_train_policy_record_fails_closed_when_missing(self) -> None: store = _PolicyStore() @@ -128,6 +178,7 @@ def test_parse_rejects_ambiguous_labels(self) -> None: base_branch = "main" enqueue_label = "ready-to-merge" blocked_label = "ready-to-merge" + stack_child_disposition_label = "stack-landed" merge_method = "merge" failure_policy = "continue_after_blocking_pr" [policies.enqueue] @@ -142,6 +193,33 @@ def test_parse_rejects_ambiguous_labels(self) -> None: with self.assertRaisesRegex(ValidationError, "must differ"): parse_merge_train_policy_toml(policy_toml) + def test_parse_rejects_stack_child_disposition_label_that_matches_train_label( + self, + ) -> None: + policy_toml = textwrap.dedent( + """ + schema_version = 1 + + [[policies]] + repository = "example/app" + base_branch = "main" + enqueue_label = "ready-to-merge" + blocked_label = "merge-blocked" + stack_child_disposition_label = "merge-blocked" + merge_method = "merge" + failure_policy = "continue_after_blocking_pr" + [policies.enqueue] + label_required = true + allowed_actor_roles = ["repo_admin"] + [policies.merge_identity] + kind = "github_token_secret" + name = "MERGE_TRAIN_TOKEN" + """ + ).strip() + + with self.assertRaisesRegex(ValidationError, "stack_child_disposition_label"): + parse_merge_train_policy_toml(policy_toml) + def test_work_graph_merge_train_policy_cli_renders_dry_run_contract(self) -> None: with TemporaryDirectory() as temporary_directory_name: result = CliRunner().invoke( @@ -177,6 +255,7 @@ def _write_policy_file(directory: str) -> Path: base_branch = "main" enqueue_label = "ready-to-merge" blocked_label = "merge-blocked" +stack_child_disposition_label = "stack-landed" merge_method = "merge" failure_policy = "pause_train" [policies.enqueue] diff --git a/tests/test_service.py b/tests/test_service.py index 96b0ec6..e1e95ab 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -30,6 +30,7 @@ from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.merge_train_policy import MergeTrainPolicyRecord +from control_plane.contracts.merge_train_policy import parse_merge_train_policy_toml from control_plane.contracts.merge_train_batch import MergeTrainBatchCandidate from control_plane.contracts.merge_train_batch import MergeTrainBatchLandingPlan from control_plane.contracts.odoo_instance_override_record import OdooConfigParameterOverride @@ -252,9 +253,7 @@ def merge_stack_child_into_parent( ) -> str: return f"stack-merge-{child_pull_request_number}-into-{parent_pull_request_number}" - def comment_pull_request( - self, *, repository: str, pull_request_number: int, body: str - ) -> str: + def comment_pull_request(self, *, repository: str, pull_request_number: int, body: str) -> str: return f"https://github.com/{repository}/pull/{pull_request_number}#issuecomment-1" def close_pull_request( @@ -263,6 +262,18 @@ def close_pull_request( return None +class _FailingChildDispositionMergeTrainGitHubClient(_FakeMergeTrainGitHubClient): + def add_pull_request_label( + self, *, repository: str, pull_request_number: int, label: str + ) -> None: + raise RuntimeError("label persistence unavailable") + + +class _StackCollapseWriteFailingFilesystemRecordStore(FilesystemRecordStore): + def write_merge_train_stack_collapse_plan_record(self, record: object) -> Path: + raise RuntimeError("stack collapse persistence unavailable") + + class _NoopMergeTrainGitHubClient: def add_pull_request_label( self, *, repository: str, pull_request_number: int, label: str @@ -377,9 +388,7 @@ class _FakeMovedRootStackedMergeTrainSnapshotReader(_FakeStackedMergeTrainSnapsh def read_merge_train_snapshot( self, *, repository: str, base_branch: str ) -> MergeTrainDryRunSnapshot: - snapshot = super().read_merge_train_snapshot( - repository=repository, base_branch=base_branch - ) + snapshot = super().read_merge_train_snapshot(repository=repository, base_branch=base_branch) return snapshot.model_copy( update={ "pull_requests": tuple( @@ -1809,7 +1818,9 @@ def test_merge_train_stack_collapse_service_executes_existing_plan_record(self) self.assertEqual(status_code, 202) self.assertEqual(payload["result"]["mode"], "execute") - self.assertEqual(payload["result"]["stack_collapse_plan"]["status"], "waiting_for_root_checks") + self.assertEqual( + payload["result"]["stack_collapse_plan"]["status"], "waiting_for_root_checks" + ) self.assertEqual( payload["result"]["stack_collapse_plan"]["mutations"][0]["status"], "mutated" ) @@ -2228,7 +2239,10 @@ def test_merge_train_batch_landing_service_plans_from_passed_candidate(self) -> self.assertEqual(status_code, 202) self.assertEqual(payload["result"]["mode"], "plan") self.assertEqual(payload["result"]["landing_plan"]["entries"][0]["status"], "planned") - self.assertEqual(listed_records[0].record_id, payload["records"]["merge_train_batch_landing_plan_record_id"]) + self.assertEqual( + listed_records[0].record_id, + payload["records"]["merge_train_batch_landing_plan_record_id"], + ) def test_merge_train_batch_landing_service_lands_existing_plan(self) -> None: with ( @@ -2442,9 +2456,7 @@ def test_merge_train_batch_landing_service_closes_stack_children_after_root_land "stack_collapse_plan_record_id": executed_record_id, }, ) - disposition_record_id = payload["records"][ - "merge_train_stack_collapse_plan_record_id" - ] + disposition_record_id = payload["records"]["merge_train_stack_collapse_plan_record_id"] stack_records = FilesystemRecordStore( state_dir ).list_merge_train_stack_collapse_plan_records( @@ -2466,6 +2478,154 @@ def test_merge_train_batch_landing_service_closes_stack_children_after_root_land "https://github.com/cbusillo/sellyouroutboard/pull/2#issuecomment-1", ) + def test_merge_train_batch_landing_persists_root_merge_before_child_record_failure( + self, + ) -> None: + with ( + TemporaryDirectory() as temporary_directory_name, + patch.dict("os.environ", {"GH_TOKEN": "token"}, clear=True), + ): + state_dir = Path(temporary_directory_name) / "state" + _seed_merge_train_policy(state_dir) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_merge_train_service_identity()), + authz_policy=_merge_train_service_policy(), + control_plane_root_path=Path(temporary_directory_name), + ) + with patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeStackedMergeTrainSnapshotReader, + ): + _, plan_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "plan", + }, + ) + plan_record_id = plan_payload["records"]["merge_train_stack_collapse_plan_record_id"] + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + _, execute_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/stack-collapse/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "execute", + "stack_collapse_plan_record_id": plan_record_id, + }, + ) + executed_record_id = execute_payload["records"][ + "merge_train_stack_collapse_plan_record_id" + ] + with patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeCollapsedRootStackedMergeTrainSnapshotReader, + ): + _, admit_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/stack-collapse/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "admit", + "stack_collapse_plan_record_id": executed_record_id, + }, + ) + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + _, build_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "build", + "candidate_record_id": admit_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + _, observe_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "observe", + "candidate_record_id": build_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + _, landing_plan_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-landing/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "plan", + "candidate_record_id": observe_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + failing_store = _StackCollapseWriteFailingFilesystemRecordStore(state_dir) + with ( + patch("control_plane.service.build_record_store", return_value=failing_store), + patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient), + ): + failing_app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_merge_train_service_identity()), + authz_policy=_merge_train_service_policy(), + control_plane_root_path=Path(temporary_directory_name), + ) + status_code, payload = _invoke_app( + failing_app, + method="POST", + path="/v1/work-graph/merge-train/batch-landing/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "land", + "landing_plan_record_id": landing_plan_payload["records"][ + "merge_train_batch_landing_plan_record_id" + ], + "stack_collapse_plan_record_id": executed_record_id, + }, + ) + landing_records = FilesystemRecordStore( + state_dir + ).list_merge_train_batch_landing_plan_records( + repository="cbusillo/sellyouroutboard", base_branch="main" + ) + + self.assertEqual(status_code, 500) + self.assertEqual(payload["error"]["code"], "internal_error") + self.assertTrue( + any( + record.landing_plan.entries[0].status == "merged" + and record.landing_plan.entries[0].merge_commit_sha == "merge-1" + for record in landing_records + ) + ) + def test_merge_train_batch_landing_service_validates_stack_before_root_merge( self, ) -> None: @@ -2593,6 +2753,171 @@ def test_merge_train_batch_landing_service_validates_stack_before_root_merge( self.assertEqual(payload["error"]["code"], "invalid_request") self.assertEqual(_FakeMergeTrainGitHubClient.land_batch_candidate_calls, 0) + def test_merge_train_batch_landing_service_requires_stack_child_policy_before_root_merge( + self, + ) -> None: + with ( + TemporaryDirectory() as temporary_directory_name, + patch.dict("os.environ", {"GH_TOKEN": "token"}, clear=True), + ): + state_dir = Path(temporary_directory_name) / "state" + _seed_merge_train_policy( + state_dir, + policy=MergeTrainPolicyRecord( + record_id="merge-train-policy-without-stack-child-label", + status="active", + source="test", + updated_at="2026-05-13T21:00:00Z", + policy=parse_merge_train_policy_toml( + """ + schema_version = 1 + + [[policies]] + repository = "cbusillo/sellyouroutboard" + base_branch = "main" + enqueue_label = "ready-to-merge" + blocked_label = "merge-blocked" + merge_method = "merge" + failure_policy = "pause_train" + + [policies.enqueue] + label_required = true + allowed_actor_roles = ["repo_owner", "repo_admin"] + + [policies.merge_identity] + kind = "github_actions_oidc" + name = "launchplane-merge-train" + + [policies.service_authz] + action = "merge_train.run_once" + product = "launchplane" + context = "launchplane" + + [policies.github_token] + env_var = "GH_TOKEN" + """ + ), + ), + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_merge_train_service_identity()), + authz_policy=_merge_train_service_policy(), + control_plane_root_path=Path(temporary_directory_name), + ) + with patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeStackedMergeTrainSnapshotReader, + ): + _, plan_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "plan", + }, + ) + plan_record_id = plan_payload["records"]["merge_train_stack_collapse_plan_record_id"] + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + _, execute_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/stack-collapse/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "execute", + "stack_collapse_plan_record_id": plan_record_id, + }, + ) + with patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeCollapsedRootStackedMergeTrainSnapshotReader, + ): + _, admit_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/stack-collapse/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "admit", + "stack_collapse_plan_record_id": execute_payload["records"][ + "merge_train_stack_collapse_plan_record_id" + ], + }, + ) + _FakeMergeTrainGitHubClient.land_batch_candidate_calls = 0 + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + _, build_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "build", + "candidate_record_id": admit_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + _, observe_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-candidate/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "observe", + "candidate_record_id": build_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + _, landing_plan_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-landing/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "plan", + "candidate_record_id": observe_payload["records"][ + "merge_train_batch_candidate_record_id" + ], + }, + ) + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/batch-landing/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mode": "land", + "landing_plan_record_id": landing_plan_payload["records"][ + "merge_train_batch_landing_plan_record_id" + ], + "stack_collapse_plan_record_id": execute_payload["records"][ + "merge_train_stack_collapse_plan_record_id" + ], + }, + ) + + self.assertEqual(status_code, 400) + self.assertEqual(payload["error"]["code"], "invalid_request") + self.assertEqual(_FakeMergeTrainGitHubClient.land_batch_candidate_calls, 0) + def test_merge_train_admission_service_uses_configured_codex_skills_policy(self) -> None: with TemporaryDirectory() as temporary_directory_name: state_dir = Path(temporary_directory_name) / "state"