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,3 @@
### Improve developer experience for multi-PR features

Boatstack now surfaces slice progression in multi-PR features by indicating the current slice relative to the total slices (e.g., `(PR 1 of 4)`). This context is shown in the terminal when interacting with the feature and is embedded directly within generated PR descriptions. Additionally, Boatstack update notices are suppressed during intermediate slice builds to ensure developers aren't distracted until the final PR of the feature is published.
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,12 @@ func publishPRCommand(arguments []string) int {
verb = "updated"
}
fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url)
if update, ok := boatstack.PostShipUpdateNotice(*repo); ok {

feature := ""
if preview, err := boatstack.ParsePRPreview(*previewPath); err == nil {
feature = preview.Feature
}
if update, ok := boatstack.PostShipUpdateNotice(*repo, feature); ok {
fmt.Printf("UPDATE_AVAILABLE=%s\nUPDATE_RELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL)
}
return 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type NextStatus struct {
VerificationStatus string `json:"verification_status"`
Feature string `json:"feature,omitempty"`
ActiveSlice string `json:"active_slice,omitempty"`
SliceIndex int `json:"slice_index,omitempty"`
TotalSlices int `json:"total_slices,omitempty"`
ObservedStage string `json:"observed_stage"`
NextOperation string `json:"next_operation"`
Reason string `json:"reason"`
Expand Down Expand Up @@ -136,6 +138,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) {
status := NextStatus{
SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
Feature: feature, ActiveSlice: slice.ID, ObservedStage: slice.Status,
SliceIndex: state.ActiveIndex + 1, TotalSlices: len(state.Slices),
}
switch slice.Status {
case "BUILD":
Expand Down Expand Up @@ -336,7 +339,11 @@ func FormatNextStatus(status NextStatus) string {
parts = append(parts, "Feature: "+status.Feature)
}
if status.ActiveSlice != "" {
parts = append(parts, "Active slice: "+status.ActiveSlice)
if status.TotalSlices > 1 {
parts = append(parts, fmt.Sprintf("Active slice: %s (PR %d of %d)", status.ActiveSlice, status.SliceIndex, status.TotalSlices))
} else {
parts = append(parts, "Active slice: "+status.ActiveSlice)
}
}
parts = append(parts, "Reason: "+status.Reason, "Next: "+status.NextOperation)
if len(status.BlockingAmbiguity) > 0 {
Expand Down
19 changes: 15 additions & 4 deletions labs/12-product-engineering-loop/product-engineering-loop/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type PRContext struct {
Mode string `json:"mode"`
Feature string `json:"feature,omitempty"`
SliceID string `json:"slice_id,omitempty"`
SliceIndex int `json:"slice_index,omitempty"`
TotalSlices int `json:"total_slices,omitempty"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
BaseCommit string `json:"base_commit"`
Expand Down Expand Up @@ -429,6 +431,8 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
sources := []PRSource{configSource}
gateStatus := map[string]string{}
sliceID := ""
sliceIndex := 0
totalSlices := 0
safety, err := CheckRepositorySafety(repo)
if err != nil {
return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err)
Expand All @@ -443,14 +447,16 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
}
sources = append(sources, managedSources...)
gateStatus = statuses
_, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed)
state, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed)
if deliveryErr != nil {
return PRContext{}, deliveryErr
}
if options.SliceID != "" && options.SliceID != slice.ID {
return PRContext{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID)
}
sliceID = slice.ID
sliceIndex = state.ActiveIndex + 1
totalSlices = len(state.Slices)
sources = append(sources, gateSources...)
}
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
Expand All @@ -474,6 +480,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
}
return PRContext{
SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, SliceID: sliceID,
SliceIndex: sliceIndex, TotalSlices: totalSlices,
BaseBranch: base, HeadBranch: head, BaseCommit: baseCommit, MergeBaseCommit: mergeBaseCommit, HeadCommit: headCommit,
ProductDiffSHA256: SHA256Bytes(diff), ContextFingerprint: SHA256Bytes(fingerprintPayload),
ChangedFiles: changed, Commits: commits, DiffStat: diffStat,
Expand Down Expand Up @@ -855,7 +862,7 @@ func PRPreviewTemplate(context PRContext) string {
return string(encoded)
}
safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack."
return strings.Join([]string{
lines := []string{
"---",
"boatstack_pr_version: 2",
"title: " + quote("Describe the product or user value of this change (e.g., 'Enable historical data migration')"),
Expand All @@ -873,8 +880,12 @@ func PRPreviewTemplate(context PRContext) string {
"## Operational safety", "", safetySummary, "",
"## Known gaps and risks", "", "List explicit gaps or say that no material gaps are known.", "",
"## Rollout and rollback", "", "Describe deployment impact and the smallest safe rollback.", "",
"<details>", "<summary>Boatstack provenance</summary>", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", "</details>", "",
}, "\n")
}
if context.TotalSlices > 1 {
lines = append(lines, fmt.Sprintf("> *(This is PR %d of %d in the `%s` feature)*", context.SliceIndex, context.TotalSlices, context.Feature), "")
}
lines = append(lines, "<details>", "<summary>Boatstack provenance</summary>", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", "</details>", "")
return strings.Join(lines, "\n")
}

func PRContextJSON(context PRContext) ([]byte, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,12 @@ func CachedUpdate(repoPath string) (UpdateCheckResult, bool) {

// PostShipUpdateNotice is deliberately best-effort: release discovery can add
// information after a successful publication, but it cannot change that result.
func PostShipUpdateNotice(repo string) (UpdateCheckResult, bool) {
func PostShipUpdateNotice(repo string, feature string) (UpdateCheckResult, bool) {
if feature != "" {
if state, err := LoadDeliveryState(repo, feature); err == nil && state.ActiveIndex + 1 < len(state.Slices) {
return UpdateCheckResult{}, false
}
}
result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
if err != nil || result.Status != "available" || !result.ShouldNotify {
return UpdateCheckResult{}, false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func TestUpdateCheckCurrentAndFailures(t *testing.T) {
if err := os.Remove(updateStatePath(repo)); err != nil {
t.Fatal(err)
}
if notice, ok := PostShipUpdateNotice(repo); ok {
if notice, ok := PostShipUpdateNotice(repo, ""); ok {
t.Fatalf("release lookup failure changed post-ship behavior: %#v", notice)
}
if cached, ok := CachedUpdate(repo); ok {
Expand Down