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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy-launchplane.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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}"
Expand Down
34 changes: 25 additions & 9 deletions .github/workflows/merge-train-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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) ;;
*)
Expand Down Expand Up @@ -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) ;;
*)
Expand Down
19 changes: 16 additions & 3 deletions control_plane/contracts/merge_train_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()


Expand Down
62 changes: 33 additions & 29 deletions control_plane/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
(
Expand All @@ -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,
)
)
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
)
Expand Down
6 changes: 6 additions & 0 deletions docs/merge-train-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"

Expand All @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions docs/service-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 27 additions & 20 deletions frontend/src/useProductSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<DriverChoice>(DEFAULT_CHOICES[0]);
const choices = useMemo(() => {
const driverChoices: DriverChoice[] = drivers.flatMap((driver) => {
Expand Down Expand Up @@ -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<string>();
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;
}
Expand Down
1 change: 1 addition & 0 deletions tests/merge_train_policy_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading