Skip to content

Commit 142b3a1

Browse files
julianknutsenclaude
andcommitted
Add TUI mutation actions with SDK-first lifecycle management
Wire action keys (c/u/x/X/D) in TUI detail view to execute lifecycle mutations with spinner feedback and confirmation prompts. Push lifecycle domain logic (permissions, labels, transitions) down to commons SDK so TUI is a thin presentation layer. SDK additions (commons/lifecycle.go): - CanPerformTransition: actor permission checking for all transitions - TransitionLabel/TransitionName: human-readable labels - TransitionRequiresInput: identifies done/accept as CLI-only actions - AvailableTransitions: combined status + permission filtering SDK additions (commons/queries.go): - ValidStatuses/ValidTypes: browse filter cycles - StatusLabel/TypeLabel: display helpers - BrowseWantedBranchAware: PR-mode branch overlay in one call - QueryFullDetail: item + completion + stamp in one call TUI changes: - Detail view: confirming → executing → result state machine - Wild-west mode: y/n confirmation before push to upstream - PR mode: skip confirmation, use per-item branches - Branch-aware detail/browse: read from mutation branches in PR mode - Action hints filtered by permission and status validity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d687e1 commit 142b3a1

12 files changed

Lines changed: 1179 additions & 83 deletions

File tree

cmd/wl/cmd_tui.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
bubbletea "github.com/charmbracelet/bubbletea"
88
"github.com/julianknutsen/wasteland/internal/commons"
9+
"github.com/julianknutsen/wasteland/internal/federation"
910
"github.com/julianknutsen/wasteland/internal/style"
1011
"github.com/julianknutsen/wasteland/internal/tui"
1112
"github.com/spf13/cobra"
@@ -41,12 +42,22 @@ func runTUI(cmd *cobra.Command, _, stderr io.Writer) error {
4142
return fmt.Errorf("pulling upstream: %w", err)
4243
}
4344

45+
// PR mode: force-push main to origin so it matches upstream.
46+
// Only the per-item mutation branches should differ.
47+
if cfg.ResolveMode() == federation.ModePR {
48+
if err := commons.PushOriginMain(cfg.LocalDir, io.Discard); err != nil {
49+
fmt.Fprintf(stderr, " warning: could not sync origin/main: %v\n", err)
50+
}
51+
}
52+
4453
upstream := cfg.Upstream
4554

4655
m := tui.New(tui.Config{
4756
DBDir: cfg.LocalDir,
4857
RigHandle: cfg.RigHandle,
4958
Upstream: upstream,
59+
Mode: cfg.ResolveMode(),
60+
Signing: cfg.Signing,
5061
})
5162

5263
p := bubbletea.NewProgram(m, bubbletea.WithAltScreen())

internal/commons/lifecycle.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,92 @@ func ResolvePushTarget(mode string, loc *ItemLocation) PushTarget {
126126
func PushOriginMain(dbDir string, stdout io.Writer) error {
127127
return PushBranchToRemoteForce(dbDir, "origin", "main", true, stdout)
128128
}
129+
130+
// CanPerformTransition checks whether actor can perform transition t on item.
131+
func CanPerformTransition(item *WantedItem, t Transition, actor string) bool {
132+
if item == nil {
133+
return false
134+
}
135+
switch t {
136+
case TransitionClaim:
137+
return true // any rig can claim
138+
case TransitionUnclaim:
139+
return item.ClaimedBy == actor || item.PostedBy == actor
140+
case TransitionDone:
141+
return item.ClaimedBy == actor
142+
case TransitionAccept:
143+
return item.PostedBy == actor && item.ClaimedBy != actor
144+
case TransitionReject:
145+
return item.PostedBy == actor
146+
case TransitionClose:
147+
return item.PostedBy == actor
148+
case TransitionDelete:
149+
return true // any rig can delete
150+
default:
151+
return false
152+
}
153+
}
154+
155+
// TransitionLabel returns a human-readable in-progress label for a transition.
156+
func TransitionLabel(t Transition) string {
157+
switch t {
158+
case TransitionClaim:
159+
return "Claiming..."
160+
case TransitionUnclaim:
161+
return "Unclaiming..."
162+
case TransitionReject:
163+
return "Rejecting..."
164+
case TransitionClose:
165+
return "Closing..."
166+
case TransitionDelete:
167+
return "Deleting..."
168+
default:
169+
return "Working..."
170+
}
171+
}
172+
173+
// TransitionName returns the short name for a transition (e.g. "claim").
174+
func TransitionName(t Transition) string {
175+
if rule, ok := transitionRules[t]; ok {
176+
return rule.name
177+
}
178+
return "unknown"
179+
}
180+
181+
// TransitionRequiresInput returns a non-empty hint if a transition requires
182+
// additional input that can't be gathered in the TUI (must use CLI).
183+
func TransitionRequiresInput(t Transition) string {
184+
switch t {
185+
case TransitionDone:
186+
return "requires evidence URL"
187+
case TransitionAccept:
188+
return "requires quality rating"
189+
default:
190+
return ""
191+
}
192+
}
193+
194+
// AvailableTransitions returns transitions valid for item that actor can perform.
195+
func AvailableTransitions(item *WantedItem, actor string) []Transition {
196+
if item == nil {
197+
return nil
198+
}
199+
all := []Transition{
200+
TransitionClaim,
201+
TransitionUnclaim,
202+
TransitionDone,
203+
TransitionAccept,
204+
TransitionReject,
205+
TransitionClose,
206+
TransitionDelete,
207+
}
208+
var result []Transition
209+
for _, t := range all {
210+
if _, err := ValidateTransition(item.Status, t); err == nil {
211+
if CanPerformTransition(item, t, actor) {
212+
result = append(result, t)
213+
}
214+
}
215+
}
216+
return result
217+
}

internal/commons/lifecycle_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,139 @@ func TestValidateTransition(t *testing.T) {
6565
}
6666
}
6767

68+
func TestCanPerformTransition(t *testing.T) {
69+
item := &WantedItem{
70+
ID: "w-test",
71+
Status: "claimed",
72+
PostedBy: "poster",
73+
ClaimedBy: "claimer",
74+
}
75+
76+
tests := []struct {
77+
name string
78+
t Transition
79+
actor string
80+
want bool
81+
}{
82+
{"claim anyone", TransitionClaim, "random", true},
83+
{"unclaim by claimer", TransitionUnclaim, "claimer", true},
84+
{"unclaim by poster", TransitionUnclaim, "poster", true},
85+
{"unclaim by other", TransitionUnclaim, "other", false},
86+
{"done by claimer", TransitionDone, "claimer", true},
87+
{"done by other", TransitionDone, "other", false},
88+
{"delete anyone", TransitionDelete, "random", true},
89+
}
90+
91+
for _, tc := range tests {
92+
t.Run(tc.name, func(t *testing.T) {
93+
got := CanPerformTransition(item, tc.t, tc.actor)
94+
if got != tc.want {
95+
t.Errorf("got %v, want %v", got, tc.want)
96+
}
97+
})
98+
}
99+
100+
// nil item always returns false.
101+
if CanPerformTransition(nil, TransitionClaim, "x") {
102+
t.Error("nil item should return false")
103+
}
104+
}
105+
106+
func TestCanPerformTransition_Accept(t *testing.T) {
107+
item := &WantedItem{
108+
ID: "w-test",
109+
Status: "in_review",
110+
PostedBy: "poster",
111+
ClaimedBy: "claimer",
112+
}
113+
114+
// Poster (non-claimer) can accept.
115+
if !CanPerformTransition(item, TransitionAccept, "poster") {
116+
t.Error("poster should be able to accept")
117+
}
118+
// Claimer cannot accept own work.
119+
if CanPerformTransition(item, TransitionAccept, "claimer") {
120+
t.Error("claimer should not be able to accept own work")
121+
}
122+
// Random cannot accept.
123+
if CanPerformTransition(item, TransitionAccept, "random") {
124+
t.Error("random should not be able to accept")
125+
}
126+
}
127+
128+
func TestTransitionLabel(t *testing.T) {
129+
tests := []struct {
130+
t Transition
131+
want string
132+
}{
133+
{TransitionClaim, "Claiming..."},
134+
{TransitionUnclaim, "Unclaiming..."},
135+
{TransitionReject, "Rejecting..."},
136+
{TransitionClose, "Closing..."},
137+
{TransitionDelete, "Deleting..."},
138+
}
139+
for _, tc := range tests {
140+
if got := TransitionLabel(tc.t); got != tc.want {
141+
t.Errorf("TransitionLabel(%v) = %q, want %q", tc.t, got, tc.want)
142+
}
143+
}
144+
// Unknown transition returns "Working..."
145+
if got := TransitionLabel(Transition(99)); got != "Working..." {
146+
t.Errorf("unknown transition label = %q, want %q", got, "Working...")
147+
}
148+
}
149+
150+
func TestTransitionName(t *testing.T) {
151+
if got := TransitionName(TransitionClaim); got != "claim" {
152+
t.Errorf("got %q, want %q", got, "claim")
153+
}
154+
if got := TransitionName(Transition(99)); got != "unknown" {
155+
t.Errorf("got %q, want %q", got, "unknown")
156+
}
157+
}
158+
159+
func TestTransitionRequiresInput(t *testing.T) {
160+
if got := TransitionRequiresInput(TransitionDone); got == "" {
161+
t.Error("done should require input")
162+
}
163+
if got := TransitionRequiresInput(TransitionAccept); got == "" {
164+
t.Error("accept should require input")
165+
}
166+
if got := TransitionRequiresInput(TransitionClaim); got != "" {
167+
t.Errorf("claim should not require input, got %q", got)
168+
}
169+
}
170+
171+
func TestAvailableTransitions(t *testing.T) {
172+
// Open item, poster is "poster", actor is "random".
173+
item := &WantedItem{
174+
ID: "w-test",
175+
Status: "open",
176+
PostedBy: "poster",
177+
}
178+
179+
// Random can claim and delete.
180+
got := AvailableTransitions(item, "random")
181+
if len(got) != 2 {
182+
t.Fatalf("expected 2 transitions, got %d: %v", len(got), got)
183+
}
184+
if got[0] != TransitionClaim || got[1] != TransitionDelete {
185+
t.Errorf("expected [claim, delete], got %v", got)
186+
}
187+
188+
// Poster can also close/delete open items — but close is only from in_review.
189+
// So poster still only gets claim + delete from open.
190+
got = AvailableTransitions(item, "poster")
191+
if len(got) != 2 {
192+
t.Fatalf("expected 2 transitions for poster on open, got %d: %v", len(got), got)
193+
}
194+
195+
// nil item returns nil.
196+
if AvailableTransitions(nil, "x") != nil {
197+
t.Error("nil item should return nil")
198+
}
199+
}
200+
68201
func TestResolvePushTarget_WildWest(t *testing.T) {
69202
loc := &ItemLocation{
70203
LocalStatus: "claimed",

0 commit comments

Comments
 (0)