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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
Expand Down
171 changes: 171 additions & 0 deletions labs/12-product-engineering-loop/product-engineering-loop/pr_phase.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading