Skip to content

Commit 740b9bd

Browse files
julianknutsenclaude
andcommitted
Add item lifecycle state machine with location-aware push strategy
Formalize the wanted item lifecycle as a first-class state machine so the TUI and command handlers share one source of truth for valid transitions. Add location detection (local/origin/upstream) and choose the minimum push operation per transition in PR mode. - lifecycle.go: ValidateTransition(), DetectItemLocation(), ResolvePushTarget() - lifecycle_test.go: table-driven tests for all transitions + push strategies - dolt.go: QueryItemStatusAsOf() using AS OF syntax - branch_helpers.go: location field, location-aware Push(), PR description refresh - pr_refresh.go: auto-update GitHub/DoltHub PR body after branch push - 8 command handlers: replace inline status checks with ValidateTransition() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8105e46 commit 740b9bd

13 files changed

Lines changed: 482 additions & 21 deletions

cmd/wl/branch_helpers.go

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type mutationContext struct {
1818
branch string // computed branch name, empty in wild-west mode
1919
noPush bool
2020
stdout io.Writer
21+
location *commons.ItemLocation // detected item location across remotes
2122
}
2223

2324
// newMutationContext creates a mutation context for the given config and wanted ID.
@@ -39,8 +40,8 @@ func (m *mutationContext) BranchName() string {
3940
return m.branch
4041
}
4142

42-
// Setup prepares the mutation context: checks dolt, syncs upstream, and
43-
// (in PR mode) checks out the item branch.
43+
// Setup prepares the mutation context: checks dolt, syncs upstream, detects
44+
// item location, and (in PR mode) checks out the item branch.
4445
// The returned cleanup function must be deferred to return to main.
4546
func (m *mutationContext) Setup() (cleanup func(), err error) {
4647
noop := func() {}
@@ -53,6 +54,13 @@ func (m *mutationContext) Setup() (cleanup func(), err error) {
5354
if syncErr != nil {
5455
fmt.Fprintf(m.stdout, " warning: upstream sync failed: %v\n", syncErr)
5556
}
57+
58+
// Detect item location across remotes (best-effort).
59+
if m.wantedID != "" {
60+
loc, _ := commons.DetectItemLocation(m.cfg.LocalDir, m.wantedID)
61+
m.location = loc
62+
}
63+
5664
if m.branch == "" {
5765
return noop, nil
5866
}
@@ -65,14 +73,49 @@ func (m *mutationContext) Setup() (cleanup func(), err error) {
6573
}
6674

6775
// Push pushes changes to the appropriate remote(s).
76+
// Uses location detection in PR mode to minimize push operations.
6877
// In wild-west mode: PushWithSync (upstream + origin).
69-
// In PR mode: PushBranch (origin only).
78+
// In PR mode with branches: PushBranch (origin only).
79+
// In PR mode on main: uses ResolvePushTarget for location-aware pushing.
7080
func (m *mutationContext) Push() error {
7181
if m.noPush {
7282
return nil
7383
}
84+
// PR mode with branch — push branch to origin, then refresh any existing PR.
7485
if m.branch != "" {
75-
return commons.PushBranch(m.cfg.LocalDir, m.branch, m.stdout)
86+
if err := commons.PushBranch(m.cfg.LocalDir, m.branch, m.stdout); err != nil {
87+
return err
88+
}
89+
m.refreshPR()
90+
return nil
91+
}
92+
93+
// Wild-west mode without location info — existing behavior.
94+
if m.location == nil || m.cfg.ResolveMode() != federation.ModePR {
95+
return commons.PushWithSync(m.cfg.LocalDir, m.stdout)
96+
}
97+
98+
// PR mode on main — use location-aware push.
99+
// Re-read local status after the mutation has been applied.
100+
m.location.LocalStatus = commons.QueryItemStatusAsOf(m.cfg.LocalDir, m.wantedID, "")
101+
target := commons.ResolvePushTarget("pr", m.location)
102+
103+
if target.PushUpstream {
104+
return commons.PushWithSync(m.cfg.LocalDir, m.stdout)
105+
}
106+
if target.PushOrigin {
107+
if err := commons.PushOriginMain(m.cfg.LocalDir, m.stdout); err != nil {
108+
return err
109+
}
110+
}
111+
112+
m.printHint(target)
113+
return nil
114+
}
115+
116+
// printHint shows a next-step hint based on the push target.
117+
func (m *mutationContext) printHint(target commons.PushTarget) {
118+
if target.Hint != "" {
119+
fmt.Fprintf(m.stdout, " %s\n", style.Dim.Render(target.Hint))
76120
}
77-
return commons.PushWithSync(m.cfg.LocalDir, m.stdout)
78121
}

cmd/wl/cmd_accept.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ func acceptCompletion(store commons.WLCommonsStore, wantedID, rigHandle string,
152152
return nil, fmt.Errorf("querying wanted item: %w", err)
153153
}
154154

155-
if item.Status != "in_review" {
156-
return nil, fmt.Errorf("wanted item %s is not in_review (status: %s)", wantedID, item.Status)
155+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionAccept); err != nil {
156+
return nil, fmt.Errorf("wanted item %s: %w", wantedID, err)
157157
}
158158

159159
if item.PostedBy != rigHandle {

cmd/wl/cmd_claim.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ func claimWanted(store commons.WLCommonsStore, wantedID, rigHandle string) (*com
8888
return nil, fmt.Errorf("querying wanted item: %w", err)
8989
}
9090

91-
if item.Status != "open" {
92-
return nil, fmt.Errorf("wanted item %s is not open (status: %s)", wantedID, item.Status)
91+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionClaim); err != nil {
92+
return nil, fmt.Errorf("wanted item %s: %w", wantedID, err)
9393
}
9494

9595
if err := store.ClaimWanted(wantedID, rigHandle); err != nil {

cmd/wl/cmd_close.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ func closeWanted(store commons.WLCommonsStore, wantedID, rigHandle string) error
8787
return fmt.Errorf("querying wanted item: %w", err)
8888
}
8989

90-
if item.Status != "in_review" {
91-
return fmt.Errorf("wanted item %s is not in_review (status: %s)", wantedID, item.Status)
90+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionClose); err != nil {
91+
return fmt.Errorf("wanted item %s: %w", wantedID, err)
9292
}
9393

9494
if item.PostedBy != rigHandle {

cmd/wl/cmd_delete.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ func deleteWanted(store commons.WLCommonsStore, wantedID string) error {
8585
return fmt.Errorf("querying wanted item: %w", err)
8686
}
8787

88-
if item.Status != "open" {
89-
return fmt.Errorf("wanted item %s is not open (status: %s)", wantedID, item.Status)
88+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionDelete); err != nil {
89+
return fmt.Errorf("wanted item %s: %w", wantedID, err)
9090
}
9191

9292
if err := store.DeleteWanted(wantedID); err != nil {

cmd/wl/cmd_done.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ func submitDone(store commons.WLCommonsStore, wantedID, rigHandle, evidence, com
102102
return fmt.Errorf("querying wanted item: %w", err)
103103
}
104104

105-
if item.Status != "claimed" {
106-
return fmt.Errorf("wanted item %s is not claimed (status: %s)", wantedID, item.Status)
105+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionDone); err != nil {
106+
return fmt.Errorf("wanted item %s: %w", wantedID, err)
107107
}
108108

109109
if item.ClaimedBy != rigHandle {

cmd/wl/cmd_reject.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ func rejectCompletion(store commons.WLCommonsStore, wantedID, rigHandle, reason
9494
return fmt.Errorf("querying wanted item: %w", err)
9595
}
9696

97-
if item.Status != "in_review" {
98-
return fmt.Errorf("wanted item %s is not in_review (status: %s)", wantedID, item.Status)
97+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionReject); err != nil {
98+
return fmt.Errorf("wanted item %s: %w", wantedID, err)
9999
}
100100

101101
if item.PostedBy != rigHandle {

cmd/wl/cmd_unclaim.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ func unclaimWanted(store commons.WLCommonsStore, wantedID, rigHandle string) (*c
8787
return nil, fmt.Errorf("querying wanted item: %w", err)
8888
}
8989

90-
if item.Status != "claimed" {
91-
return nil, fmt.Errorf("wanted item %s is not claimed (status: %s)", wantedID, item.Status)
90+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionUnclaim); err != nil {
91+
return nil, fmt.Errorf("wanted item %s: %w", wantedID, err)
9292
}
9393

9494
if item.ClaimedBy != rigHandle && item.PostedBy != rigHandle {

cmd/wl/cmd_update.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ func updateWanted(store commons.WLCommonsStore, wantedID string, fields *commons
160160
return fmt.Errorf("querying wanted item: %w", err)
161161
}
162162

163-
if item.Status != "open" {
164-
return fmt.Errorf("wanted item %s is not open (status: %s)", wantedID, item.Status)
163+
if _, err := commons.ValidateTransition(item.Status, commons.TransitionUpdate); err != nil {
164+
return fmt.Errorf("wanted item %s: %w", wantedID, err)
165165
}
166166

167167
if err := store.UpdateWanted(wantedID, fields); err != nil {

cmd/wl/pr_refresh.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
9+
"github.com/julianknutsen/wasteland/internal/federation"
10+
"github.com/julianknutsen/wasteland/internal/remote"
11+
)
12+
13+
// refreshPR updates the description of an existing PR after a successful push.
14+
// Best-effort: failures print a warning but don't block the mutation.
15+
func (m *mutationContext) refreshPR() {
16+
if m.branch == "" {
17+
return
18+
}
19+
20+
switch m.cfg.ResolveProviderType() {
21+
case "github":
22+
m.refreshGitHubPR()
23+
case "dolthub":
24+
m.refreshDoltHubPR()
25+
}
26+
}
27+
28+
func (m *mutationContext) refreshGitHubPR() {
29+
ghPath, err := exec.LookPath("gh")
30+
if err != nil {
31+
return
32+
}
33+
34+
client := newGHClient(ghPath)
35+
head := m.cfg.ForkOrg + ":" + m.branch
36+
_, number := client.FindPR(m.cfg.Upstream, head)
37+
if number == "" {
38+
return
39+
}
40+
41+
body, err := m.generateDiffMarkdown()
42+
if err != nil {
43+
return
44+
}
45+
46+
if err := client.UpdatePR(m.cfg.Upstream, number, map[string]string{"body": body}); err != nil {
47+
fmt.Fprintf(m.stdout, " warning: could not update PR description: %v\n", err)
48+
return
49+
}
50+
fmt.Fprintf(m.stdout, " Updated PR description\n")
51+
}
52+
53+
func (m *mutationContext) refreshDoltHubPR() {
54+
token := os.Getenv("DOLTHUB_TOKEN")
55+
if token == "" {
56+
return
57+
}
58+
59+
upstreamOrg, db, err := federation.ParseUpstream(m.cfg.Upstream)
60+
if err != nil {
61+
return
62+
}
63+
64+
provider := remote.NewDoltHubProvider(token)
65+
_, prID := provider.FindPR(upstreamOrg, db, m.cfg.ForkOrg, m.branch)
66+
if prID == "" {
67+
return
68+
}
69+
70+
body, err := m.generateDiffMarkdown()
71+
if err != nil {
72+
return
73+
}
74+
75+
doltPath, _ := exec.LookPath("dolt")
76+
title := wantedTitleFromBranch(doltPath, m.cfg.LocalDir, m.branch)
77+
prTitle := fmt.Sprintf("[wl] %s", title)
78+
79+
if err := provider.UpdatePR(upstreamOrg, db, prID, prTitle, body); err != nil {
80+
fmt.Fprintf(m.stdout, " warning: could not update PR description: %v\n", err)
81+
return
82+
}
83+
fmt.Fprintf(m.stdout, " Updated PR description\n")
84+
}
85+
86+
// generateDiffMarkdown renders a markdown diff between the branch and its base.
87+
// Uses three-dot diff (base...branch) which compares refs directly and does
88+
// not depend on the current checkout.
89+
func (m *mutationContext) generateDiffMarkdown() (string, error) {
90+
doltPath, err := exec.LookPath("dolt")
91+
if err != nil {
92+
return "", err
93+
}
94+
95+
base := diffBase(m.cfg.LocalDir, doltPath)
96+
97+
var buf bytes.Buffer
98+
if err := renderMarkdownDiff(&buf, m.cfg.LocalDir, doltPath, m.branch, base); err != nil {
99+
return "", err
100+
}
101+
return buf.String(), nil
102+
}

0 commit comments

Comments
 (0)