diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a53f0ee..e90429b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/ebc2162014928c17debb1ce5186f1d6e20608f36/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/c03b391323146e8d05b6049e8ba6506c8fdfdf97/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index d422886..af1de35 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "64703c794db57cec757149c25037a0f7ad28a922be86436261a5094a9bb26f30", + "CONTRIBUTING.md": "fdfd3c320151827975d09023ad8746cdeb8efdad8fe791679a97b33e75fc26e8", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -129,7 +129,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "c133dbf907dc86ca5aacec154f6e4a63e7aa9f5f0ebdd76e1803375b5700675a", + "boatstack/next.go": "7bd3d143f74452399b875da61a5353e91cbf6218bea5516a39fc13126a5fa042", "boatstack/next_actor_conformance_test.go": "23c055bcc99d889344c3f86c7940eb9ef2ef6f4ddab34e91cf215c9d078f5d42", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_response.go": "11decf2e3b236cbaa183980946ec17ffbbbb1af9c08bd11a466a8487bf229d5f", @@ -146,13 +146,15 @@ "boatstack/planning_first_write_conformance_test.go": "873097aa9384b75bf01e74a475f3ec2ac7cca4a28f733e82f2c82959032c6a30", "boatstack/planning_test.go": "06ec7022222d926040c3ae28b84ab50c3d2f804ae6473e61b303804dd992d884", "boatstack/pr.go": "b6df3e000dd6d34ecb6575385e44b2f6ee84a8f35ad5696e299177a7cddd7629", + "boatstack/pr_phase.go": "59f8cbb75b6b538a5345474acd6a725450979579bf8ecf9591956cbbe1cc4737", + "boatstack/pr_phase_conformance_test.go": "bc9c834e9c4ed43b35d81abafd7b1bf2a264ea2a8c4a4ec9758ee18d1d438968", "boatstack/pr_test.go": "2e7709e2f163489a29ea3e7eb4bc932cfe6829b30d168f1aea9e942acbc3b4e7", "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", "boatstack/provision.go": "eb7333a73331b011adc93f59a2d97415d850c2e588f0e2bbb5116984e2ef927d", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", - "boatstack/recovery.go": "8e35cf7f0d73ec9708e00a5a9bfd5f30ec832537cb0bffc254581bb6b8ae33ea", + "boatstack/recovery.go": "8c963dbaa30adcac929c52944171f76843f240194e53a2e2d4c1ff65463a93e4", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", @@ -204,10 +206,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "c83785a7651082402839257369b488aa906abb6395b9b99130cea42df19d7a33", + "docs/evidence-engineered-coding.md": "359548672c7d786d81838f2bee6c74c0861e050a30b7e2a089775b1011aaf1c4", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "b5dd5544c932a4dbd549047fc90cecec8e9c14842daae12feb2500a0a566896f", + "docs/public-claims.json": "df83f3ca329170435ede3c3d75d04f99b5266296a15bb44cc564abdf57ebb3a1", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -221,7 +223,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "e8b820e1c2f9bfc28e661842324f7c4455f57be29290d0909634a00b71e5919f", + "labs/diagram-json/plan.lock.json": "fbc797694160cab69ae7287cf99d15d4391a5e2a0838664eb45f24832ce7f1c8", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -353,12 +355,13 @@ "release-notes/2026-07-28-minimum-app-permissions.md": "9ef97e32e5591966ef34ba23f4e0a7aa14d061a4ac5f72f5f4dcecc9bfb62a89", "release-notes/2026-07-28-native-auto-merge-conformance.md": "67d0fab76fd4911b5836d19d06b319537cb1807650d35dbd534627a2c3622757", "release-notes/2026-07-28-operator-frontier-next-actor.md": "7e769625a2beb8a204d2d79158c18bdac350656de5c55998988a8a5aba2c319a", + "release-notes/2026-07-28-pr-phase-observation.md": "8c5615013eb88ce9561d30897e47f6fa967e0157c4c245f0c35a1f31e4e132e2", "release-notes/2026-07-28-protected-native-auto-merge.md": "67dc76a6e7ce51034a0eadc541ba7a8946cfcabe5433cedc25db55321dfb8b62" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "ebc2162014928c17debb1ce5186f1d6e20608f36", + "commit": "c03b391323146e8d05b6049e8ba6506c8fdfdf97", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/next.go b/boatstack/next.go index 8e7ae91..7c54300 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -27,6 +27,10 @@ type NextStatus struct { Reason string `json:"reason"` BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"` Lifecycle string `json:"lifecycle,omitempty"` + PRPhase string `json:"pr_phase,omitempty"` + PRReviewDecision string `json:"pr_review_decision,omitempty"` + PRMergeState string `json:"pr_merge_state,omitempty"` + PRFailingChecks []string `json:"pr_failing_checks,omitempty"` PRURL string `json:"pr_url,omitempty"` HeadBranch string `json:"head_branch,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` @@ -147,13 +151,31 @@ func nextForPublished(repo string, state DeliveryState) NextStatus { TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none", Lifecycle: pr.Lifecycle, PRURL: pr.URL, HeadBranch: pr.Branch, ParentDelivery: state.ParentDelivery, + PRPhase: string(pr.Phase), PRReviewDecision: pr.ReviewDecision, + PRMergeState: pr.MergeState, PRFailingChecks: pr.FailingChecks, } switch pr.Lifecycle { case "PUBLISHED_MERGED": status.ObservedStage = "FEATURE_COMPLETE" status.Reason = fmt.Sprintf("The published PR for feature %q is merged.", state.Feature) case "PUBLISHED_OPEN": - status.Reason = fmt.Sprintf("Feature %q is published in an open PR; review and required checks may still produce a corrective delivery.", state.Feature) + // The observed PR phase sharpens the reason when it is known; the + // pre-phase sentence remains the fallback so a degraded observation + // reads exactly as it always did. + switch pr.Phase { + case PRPhaseChecksPending: + status.Reason = fmt.Sprintf("Feature %q is published; checks on its PR are still running.", state.Feature) + case PRPhaseChecksFailing: + status.Reason = fmt.Sprintf("Feature %q is published; %d PR check(s) are failing (%s).", state.Feature, pr.ChecksFailed, strings.Join(pr.FailingChecks, ", ")) + case PRPhaseChangesRequested: + status.Reason = fmt.Sprintf("Feature %q is published; its PR review requested changes.", state.Feature) + case PRPhaseReviewRequired: + status.Reason = fmt.Sprintf("Feature %q is published; its PR checks pass and a required review approval is still owed.", state.Feature) + case PRPhaseMergeEligible: + status.Reason = fmt.Sprintf("Feature %q is published; its PR has passing checks, satisfied reviews, and a clean merge state.", state.Feature) + default: + status.Reason = fmt.Sprintf("Feature %q is published in an open PR; review and required checks may still produce a corrective delivery.", state.Feature) + } case "PUBLISHED_CLOSED": status.Reason = fmt.Sprintf("The PR for feature %q is closed without a verified merge; a future correction requires a fresh PR.", state.Feature) default: @@ -411,6 +433,15 @@ func FormatNextStatus(status NextStatus) string { if status.Lifecycle != "" { parts = append(parts, "Lifecycle: "+status.Lifecycle) } + // An Unknown phase adds nothing the lifecycle line does not already say, + // so only a positively derived phase earns a line. + if status.PRPhase != "" && status.PRPhase != string(PRPhaseUnknown) { + phase := "PR phase: " + status.PRPhase + if len(status.PRFailingChecks) > 0 { + phase += " (" + strings.Join(status.PRFailingChecks, ", ") + ")" + } + parts = append(parts, phase) + } if status.PRURL != "" { parts = append(parts, "PR: "+status.PRURL) } diff --git a/boatstack/pr_phase.go b/boatstack/pr_phase.go new file mode 100644 index 0000000..2bacbd7 --- /dev/null +++ b/boatstack/pr_phase.go @@ -0,0 +1,171 @@ +package boatstack + +import "strings" + +// PRPhase is the observed position of a published pull request between +// publication and merge. It is derived ONLY from a live GitHub observation +// (checks, review decision, merge state) at the moment of a read-only +// resolution; it is never persisted, recorded by an agent, or accepted from +// text. Anything the derivation cannot classify with certainty degrades to +// PRPhaseUnknown, which downstream classification treats as the operator's. +// control-law: pr-phase-derives-only-from-live-observation +type PRPhase string + +const ( + // PRPhaseUnknown: the observation is missing, partial, or names a + // combination this derivation does not understand. Fail-closed default. + PRPhaseUnknown PRPhase = "PR_UNKNOWN" + // PRPhaseChecksPending: the PR is open and at least one check has not finished. + PRPhaseChecksPending PRPhase = "PR_CHECKS_PENDING" + // PRPhaseChecksFailing: the PR is open and at least one check concluded badly. + PRPhaseChecksFailing PRPhase = "PR_CHECKS_FAILING" + // PRPhaseChangesRequested: a reviewer requested changes. This outranks check + // status: a human review verdict is a stronger signal than CI and hands the + // step to the operator regardless of what the checks are doing. + PRPhaseChangesRequested PRPhase = "PR_CHANGES_REQUESTED" + // PRPhaseReviewRequired: checks are green but a required review approval is + // still owed. Granting approval is never Boatstack's or the agent's to do. + PRPhaseReviewRequired PRPhase = "PR_REVIEW_REQUIRED" + // PRPhaseMergeEligible: checks green, review satisfied, and GitHub reports + // the branch cleanly mergeable. + PRPhaseMergeEligible PRPhase = "PR_MERGE_ELIGIBLE" + // PRPhaseMerged / PRPhaseClosed: terminal, mirrors the PR lifecycle. + PRPhaseMerged PRPhase = "PR_MERGED" + PRPhaseClosed PRPhase = "PR_CLOSED" +) + +// prStatusCheck is one element of gh's statusCheckRollup array. GitHub emits +// two shapes — CheckRun (Actions/checks API: status+conclusion+name) and +// StatusContext (legacy commit status: state+context) — and this struct holds +// the union so one decode covers both. +type prStatusCheck struct { + TypeName string `json:"__typename"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Context string `json:"context"` + State string `json:"state"` +} + +// prCheckSummary aggregates a statusCheckRollup. Unrecognized is sticky: one +// entry the tables below cannot classify poisons the whole summary, because a +// phase derived from a partially understood rollup would be a guess. +type prCheckSummary struct { + Total int + Passed int + Failed int + Pending int + Failing []string + Unrecognized bool +} + +// prFailingChecksCap bounds the failing-check name list carried into status +// output so one enormous check matrix cannot flood a rendered response. +const prFailingChecksCap = 8 + +func summarizeCheckRollup(entries []prStatusCheck) prCheckSummary { + summary := prCheckSummary{Total: len(entries)} + for _, entry := range entries { + name := strings.TrimSpace(entry.Name) + if name == "" { + name = strings.TrimSpace(entry.Context) + } + switch classifyStatusCheck(entry) { + case "passed": + summary.Passed++ + case "pending": + summary.Pending++ + case "failed": + summary.Failed++ + if name != "" && len(summary.Failing) < prFailingChecksCap { + summary.Failing = append(summary.Failing, name) + } + default: + summary.Unrecognized = true + } + } + return summary +} + +// classifyStatusCheck maps one rollup entry to passed/pending/failed, or "" +// when the entry's vocabulary is not in the tables. The typename is trusted +// first; when absent, the populated field set identifies the shape. +func classifyStatusCheck(entry prStatusCheck) string { + shape := strings.TrimSpace(entry.TypeName) + if shape == "" { + switch { + case entry.State != "" || entry.Context != "": + shape = "StatusContext" + case entry.Status != "" || entry.Conclusion != "": + shape = "CheckRun" + } + } + switch shape { + case "CheckRun": + if !strings.EqualFold(strings.TrimSpace(entry.Status), "COMPLETED") { + return "pending" + } + switch strings.ToUpper(strings.TrimSpace(entry.Conclusion)) { + case "SUCCESS", "NEUTRAL", "SKIPPED": + return "passed" + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE": + return "failed" + } + case "StatusContext": + switch strings.ToUpper(strings.TrimSpace(entry.State)) { + case "SUCCESS": + return "passed" + case "PENDING", "EXPECTED": + return "pending" + case "FAILURE", "ERROR": + return "failed" + } + } + return "" +} + +// derivePRPhase turns one live observation into a PRPhase. The derivation is +// pure and total: every input lands somewhere, and everything outside the +// explicitly understood combinations lands on PRPhaseUnknown. Notably absent +// on purpose: DIRTY/BEHIND/BLOCKED/DRAFT merge states (conflicts, stale base, +// branch protection this derivation cannot see, drafts) all stay Unknown so +// they reach the operator instead of being guessed at. +func derivePRPhase(prState string, checks prCheckSummary, reviewDecision, mergeState string) PRPhase { + switch strings.ToUpper(strings.TrimSpace(prState)) { + case "MERGED": + return PRPhaseMerged + case "CLOSED": + return PRPhaseClosed + case "OPEN": + default: + return PRPhaseUnknown + } + if checks.Unrecognized { + return PRPhaseUnknown + } + decision := strings.ToUpper(strings.TrimSpace(reviewDecision)) + if decision == "CHANGES_REQUESTED" { + return PRPhaseChangesRequested + } + if checks.Failed > 0 { + return PRPhaseChecksFailing + } + if checks.Pending > 0 { + return PRPhaseChecksPending + } + switch decision { + case "REVIEW_REQUIRED": + return PRPhaseReviewRequired + case "", "APPROVED": + default: + return PRPhaseUnknown + } + // An empty rollup means no checks are configured; green-by-absence is + // acceptable only because merge eligibility still requires GitHub itself + // to report the branch cleanly mergeable below. + switch strings.ToUpper(strings.TrimSpace(mergeState)) { + case "CLEAN", "HAS_HOOKS": + return PRPhaseMergeEligible + } + return PRPhaseUnknown +} diff --git a/boatstack/pr_phase_conformance_test.go b/boatstack/pr_phase_conformance_test.go new file mode 100644 index 0000000..6df7140 --- /dev/null +++ b/boatstack/pr_phase_conformance_test.go @@ -0,0 +1,232 @@ +package boatstack + +// control-law: pr-phase-derives-only-from-live-observation +// +// The post-publish PR phase (checks pending/failing, changes requested, +// review required, merge eligible, merged, closed) is derived exclusively +// from one live GitHub observation at read-only resolution time. It is never +// persisted, never accepted from an agent's text, and every observation the +// derivation does not understand with certainty degrades to PR_UNKNOWN. +// Companion law re-pinned here: gate resolution stays network-free — a +// non-terminal observation must leave the delivery ledger byte-identical +// (persistObservedTerminalPRState caches terminal lifecycles only). +// +// Test classes: positive (each understood observation → its phase, through +// the real ResolveNext path, over both statusCheckRollup shapes), negative +// (degraded/malformed/unrecognized observations → PR_UNKNOWN), bypass (a +// non-terminal observation writes nothing), failure-state (an older gh that +// rejects the enriched field list still yields the legacy lifecycle). + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" +) + +const ( + rollupCheckRunPass = `{"__typename":"CheckRun","name":"unit","status":"COMPLETED","conclusion":"SUCCESS"}` + rollupCheckRunFail = `{"__typename":"CheckRun","name":"unit","status":"COMPLETED","conclusion":"FAILURE"}` + rollupCheckRunPending = `{"__typename":"CheckRun","name":"unit","status":"IN_PROGRESS","conclusion":""}` + rollupContextPass = `{"__typename":"StatusContext","context":"ci/lint","state":"SUCCESS"}` + rollupContextFail = `{"__typename":"StatusContext","context":"ci/lint","state":"FAILURE"}` + rollupContextPending = `{"__typename":"StatusContext","context":"ci/lint","state":"PENDING"}` + rollupUnrecognized = `{"__typename":"CheckRun","name":"novel","status":"COMPLETED","conclusion":"SOMETHING_NEW"}` +) + +func phaseObservationPayload(prState, reviewDecision, mergeState, rollup string) func(string, ...string) (string, error) { + return func(_ string, _ ...string) (string, error) { + return fmt.Sprintf( + `{"state":%q,"headRefName":"feat/phase","headRefOid":"head1","url":"https://example.invalid/pr/9","baseRefName":"main","mergeable":"MERGEABLE","mergeStateStatus":%q,"reviewDecision":%q,"statusCheckRollup":[%s]}`, + prState, mergeState, reviewDecision, rollup), nil + } +} + +func publishedPhaseRepo(t *testing.T) string { + t.Helper() + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "phased", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "phased", "feat/phase", "https://example.invalid/pr/9", "") + return repo +} + +// Positive: every understood live observation maps to exactly one phase, +// through the real ResolveNext path, over both rollup shapes. +func TestResolveNextDerivesPRPhaseFromLiveObservation(t *testing.T) { + for _, test := range []struct { + name string + prState string + reviewDecision string + mergeState string + rollup string + wantPhase PRPhase + wantStage string + reasonContains string + }{ + {"green_approved_clean_is_merge_eligible", "OPEN", "APPROVED", "CLEAN", rollupCheckRunPass + "," + rollupContextPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"}, + {"no_required_review_green_clean_is_merge_eligible", "OPEN", "", "CLEAN", rollupCheckRunPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"}, + {"no_checks_configured_green_by_absence", "OPEN", "APPROVED", "CLEAN", "", PRPhaseMergeEligible, "PUBLISHED", "clean merge state"}, + {"has_hooks_is_merge_eligible", "OPEN", "APPROVED", "HAS_HOOKS", rollupCheckRunPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"}, + {"failing_check_run", "OPEN", "APPROVED", "CLEAN", rollupCheckRunFail + "," + rollupContextPass, PRPhaseChecksFailing, "PUBLISHED", "failing (unit)"}, + {"failing_status_context", "OPEN", "", "CLEAN", rollupCheckRunPass + "," + rollupContextFail, PRPhaseChecksFailing, "PUBLISHED", "failing (ci/lint)"}, + {"pending_check_run", "OPEN", "", "CLEAN", rollupCheckRunPending, PRPhaseChecksPending, "PUBLISHED", "still running"}, + {"pending_status_context", "OPEN", "", "CLEAN", rollupContextPending, PRPhaseChecksPending, "PUBLISHED", "still running"}, + {"review_required_after_green", "OPEN", "REVIEW_REQUIRED", "BLOCKED", rollupCheckRunPass, PRPhaseReviewRequired, "PUBLISHED", "required review approval"}, + {"changes_requested_outranks_failing_checks", "OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunFail, PRPhaseChangesRequested, "PUBLISHED", "requested changes"}, + {"merged_pr_is_terminal", "MERGED", "", "", "", PRPhaseMerged, "FEATURE_COMPLETE", "is merged"}, + {"closed_pr_is_terminal", "CLOSED", "", "", "", PRPhaseClosed, "PUBLISHED", "closed without a verified merge"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := publishedPhaseRepo(t) + withRecoveryGh(t, phaseObservationPayload(test.prState, test.reviewDecision, test.mergeState, test.rollup)) + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.PRPhase != string(test.wantPhase) { + t.Fatalf("phase = %q, want %q (%#v)", status.PRPhase, test.wantPhase, status) + } + if status.ObservedStage != test.wantStage { + t.Fatalf("stage = %q, want %q", status.ObservedStage, test.wantStage) + } + if !strings.Contains(status.Reason, test.reasonContains) { + t.Fatalf("reason %q does not mention %q", status.Reason, test.reasonContains) + } + if status.NextOperation != "none" { + t.Fatalf("phase observation must not change the prescribed operation yet: %q", status.NextOperation) + } + }) + } +} + +// Positive: the failing-check names ride along for status output, bounded by +// the cap so a huge check matrix cannot flood a rendered response. +func TestFailingCheckNamesSurfaceBounded(t *testing.T) { + repo := publishedPhaseRepo(t) + entries := make([]string, 0, prFailingChecksCap+4) + for i := 0; i < prFailingChecksCap+4; i++ { + entries = append(entries, fmt.Sprintf(`{"__typename":"CheckRun","name":"job-%02d","status":"COMPLETED","conclusion":"FAILURE"}`, i)) + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", strings.Join(entries, ","))) + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.PRPhase != string(PRPhaseChecksFailing) { + t.Fatalf("phase = %q", status.PRPhase) + } + if len(status.PRFailingChecks) != prFailingChecksCap { + t.Fatalf("failing names = %d, want cap %d", len(status.PRFailingChecks), prFailingChecksCap) + } + if status.PRFailingChecks[0] != "job-00" { + t.Fatalf("unexpected first failing check: %v", status.PRFailingChecks) + } +} + +// Negative: degraded, malformed, or partially understood observations all +// land on PR_UNKNOWN and keep the pre-phase behavior intact. +func TestPRPhaseFailsClosedToUnknown(t *testing.T) { + for _, test := range []struct { + name string + gh func(string, ...string) (string, error) + wantLifecycle string + }{ + {"gh_unavailable", func(string, ...string) (string, error) { return "", errors.New("not authenticated") }, "PUBLISHED_UNKNOWN"}, + {"malformed_payload", func(string, ...string) (string, error) { return "not json", nil }, "PUBLISHED_UNKNOWN"}, + {"unrecognized_rollup_entry", phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupUnrecognized), "PUBLISHED_OPEN"}, + {"unrecognized_review_decision", phaseObservationPayload("OPEN", "SOMETHING_NEW", "CLEAN", rollupCheckRunPass), "PUBLISHED_OPEN"}, + {"dirty_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass), "PUBLISHED_OPEN"}, + {"behind_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "APPROVED", "BEHIND", rollupCheckRunPass), "PUBLISHED_OPEN"}, + {"draft_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "", "DRAFT", ""), "PUBLISHED_OPEN"}, + } { + t.Run(test.name, func(t *testing.T) { + repo := publishedPhaseRepo(t) + withRecoveryGh(t, test.gh) + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.PRPhase != string(PRPhaseUnknown) { + t.Fatalf("phase = %q, want PR_UNKNOWN", status.PRPhase) + } + if status.Lifecycle != test.wantLifecycle { + t.Fatalf("lifecycle = %q, want %q", status.Lifecycle, test.wantLifecycle) + } + if status.NextOperation != "none" { + t.Fatalf("unexpected operation %q", status.NextOperation) + } + if rendered := FormatNextStatus(status); strings.Contains(rendered, "PR phase:") { + t.Fatalf("an Unknown phase must not earn a rendered line:\n%s", rendered) + } + }) + } +} + +// Bypass: a non-terminal observation — however rich — must leave the delivery +// ledger byte-identical. Only a terminal lifecycle is cached, exactly as +// before the enrichment. +func TestNonTerminalPhaseObservationWritesNothing(t *testing.T) { + repo := publishedPhaseRepo(t) + statePath, err := deliveryStatePath(repo, "phased") + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunFail)) + if _, err := ResolveNext(repo, ""); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a non-terminal observation modified the delivery ledger") + } + + // Relation: the terminal cache write still happens after the enrichment. + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + if _, err := ResolveNext(repo, ""); err != nil { + t.Fatal(err) + } + state, err := LoadDeliveryState(repo, "phased") + if err != nil { + t.Fatal(err) + } + if state.Slices[len(state.Slices)-1].PRState != "PUBLISHED_MERGED" { + t.Fatalf("terminal lifecycle was not cached: %#v", state.Slices) + } +} + +// Failure-state: an older gh that rejects the enriched field list must not +// cost the basic lifecycle observation — the observer falls back to the +// legacy field list and the phase stays Unknown. +func TestObservationFallsBackToLegacyFieldsOnOlderGh(t *testing.T) { + repo := publishedPhaseRepo(t) + var requested []string + withRecoveryGh(t, func(_ string, args ...string) (string, error) { + fields := args[len(args)-1] + requested = append(requested, fields) + if strings.Contains(fields, "statusCheckRollup") { + return "", errors.New("unknown JSON field: statusCheckRollup") + } + return `{"state":"OPEN","headRefName":"feat/phase","headRefOid":"head1","url":"https://example.invalid/pr/9"}`, nil + }) + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.Lifecycle != "PUBLISHED_OPEN" { + t.Fatalf("legacy lifecycle lost: %q", status.Lifecycle) + } + if status.PRPhase != string(PRPhaseUnknown) { + t.Fatalf("phase = %q, want PR_UNKNOWN on a legacy observation", status.PRPhase) + } + if len(requested) != 2 || requested[0] != publishedPRFields || requested[1] != publishedPRLegacyFields { + t.Fatalf("unexpected field negotiation: %v", requested) + } +} diff --git a/boatstack/recovery.go b/boatstack/recovery.go index 108f79b..5651979 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -24,6 +24,8 @@ type RecoveryStatus struct { Slice string `json:"slice,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` Lifecycle string `json:"lifecycle,omitempty"` + PRPhase string `json:"pr_phase,omitempty"` + PRFailingChecks []string `json:"pr_failing_checks,omitempty"` PRURL string `json:"pr_url,omitempty"` HeadBranch string `json:"head_branch,omitempty"` ObservedPRHeadSHA string `json:"observed_pr_head_sha,omitempty"` @@ -49,8 +51,30 @@ type publishedPRObservation struct { URL string Branch string HeadSHA string + // Post-publish position, observed live and never persisted. Phase is the + // fail-closed classification; the remaining fields carry the raw facts it + // was derived from so status output can explain the classification. + // control-law: pr-phase-derives-only-from-live-observation + Phase PRPhase + BaseBranch string + ReviewDecision string + MergeState string + FailingChecks []string + ChecksTotal int + ChecksPassed int + ChecksFailed int + ChecksPending int } +// publishedPRFields is the field list for the single live PR observation. +// publishedPRLegacyFields is the pre-phase list kept as a fallback so an older +// gh binary that rejects the newer fields still yields the basic lifecycle +// observation it always did. +const ( + publishedPRFields = "state,headRefName,headRefOid,url,baseRefName,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision" + publishedPRLegacyFields = "state,headRefName,headRefOid,url" +) + var recoveryGh = func(repo string, arguments ...string) (string, error) { return commandOutput(repo, "gh", arguments...) } @@ -173,7 +197,7 @@ func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBran func observePublishedPR(repo string, state DeliveryState) publishedPRObservation { branch, _, prURL := deliveryBranchAndSlice(state) - observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch} + observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch, Phase: PRPhaseUnknown} target := prURL if target == "" { target = branch @@ -181,15 +205,26 @@ func observePublishedPR(repo string, state DeliveryState) publishedPRObservation if target == "" { return observation } - value, err := recoveryGh(repo, "pr", "view", target, "--json", "state,headRefName,headRefOid,url") + value, err := recoveryGh(repo, "pr", "view", target, "--json", publishedPRFields) if err != nil { - return observation + // An older gh may reject the phase fields; fall back to the legacy + // list so the lifecycle observation this function always produced is + // never lost to the enrichment. The phase stays Unknown. + value, err = recoveryGh(repo, "pr", "view", target, "--json", publishedPRLegacyFields) + if err != nil { + return observation + } } var payload struct { - State string `json:"state"` - HeadRefName string `json:"headRefName"` - HeadRefOID string `json:"headRefOid"` - URL string `json:"url"` + State string `json:"state"` + HeadRefName string `json:"headRefName"` + HeadRefOID string `json:"headRefOid"` + URL string `json:"url"` + BaseRefName string `json:"baseRefName"` + Mergeable string `json:"mergeable"` + MergeStateStatus string `json:"mergeStateStatus"` + ReviewDecision string `json:"reviewDecision"` + StatusCheckRollup []prStatusCheck `json:"statusCheckRollup"` } if DecodeJSON("inspect published PR", target, []byte(value), &payload) != nil { return observation @@ -209,6 +244,16 @@ func observePublishedPR(repo string, state DeliveryState) publishedPRObservation case "CLOSED": observation.Lifecycle = "PUBLISHED_CLOSED" } + checks := summarizeCheckRollup(payload.StatusCheckRollup) + observation.BaseBranch = payload.BaseRefName + observation.ReviewDecision = payload.ReviewDecision + observation.MergeState = payload.MergeStateStatus + observation.FailingChecks = checks.Failing + observation.ChecksTotal = checks.Total + observation.ChecksPassed = checks.Passed + observation.ChecksFailed = checks.Failed + observation.ChecksPending = checks.Pending + observation.Phase = derivePRPhase(payload.State, checks, payload.ReviewDecision, payload.MergeStateStatus) return observation } @@ -417,6 +462,8 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { pr := observePublishedPR(repo, selected) persistObservedTerminalPRState(repo, selected, pr) status.Lifecycle = pr.Lifecycle + status.PRPhase = string(pr.Phase) + status.PRFailingChecks = pr.FailingChecks status.PRURL = pr.URL status.ObservedPRHeadSHA = pr.HeadSHA if pr.Branch != "" { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index d25d8cd..9ab4834 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`ebc2162014928c17debb1ce5186f1d6e20608f36`](https://github.com/operatorstack/intelligence-flow/tree/ebc2162014928c17debb1ce5186f1d6e20608f36/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`c03b391323146e8d05b6049e8ba6506c8fdfdf97`](https://github.com/operatorstack/intelligence-flow/tree/c03b391323146e8d05b6049e8ba6506c8fdfdf97/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 3d65872..f1428e8 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "ebc2162014928c17debb1ce5186f1d6e20608f36", + "source_commit": "c03b391323146e8d05b6049e8ba6506c8fdfdf97", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:ebc2162014928c17debb1ce5186f1d6e20608f36" + "last_verified_version": "source:c03b391323146e8d05b6049e8ba6506c8fdfdf97" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 2c47415..22a9105 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "ebc2162014928c17debb1ce5186f1d6e20608f36", + "source_commit": "c03b391323146e8d05b6049e8ba6506c8fdfdf97", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-28-pr-phase-observation.md b/release-notes/2026-07-28-pr-phase-observation.md new file mode 100644 index 0000000..3e6b079 --- /dev/null +++ b/release-notes/2026-07-28-pr-phase-observation.md @@ -0,0 +1,5 @@ +### Status now shows where your published PR actually stands + +After you publish, `next-status` and `recovery-status` observe the live pull request and report its position: checks still running, checks failing (with the failing check names), changes requested, a required review still owed, or clean and eligible to merge. Before this, a published feature reported only open/merged/closed, and you had to open GitHub to learn why an open PR was not moving. + +The new detail comes from the same single read-only GitHub lookup Boatstack already performed, and it is never stored: anything the observation cannot classify with certainty is reported as unknown and left for you, and your delivery records are written only when the PR reaches a terminal merged or closed state, exactly as before. If your `gh` version does not support the richer lookup, status falls back to the previous behavior unchanged.