Skip to content

Commit 00aac36

Browse files
julianknutsenclaude
andcommitted
Add wl unclaim command to release claimed items back to open
Allows claimer or poster to revert claimed → open, clearing claimed_by. Includes store layer, unit tests, testscript error cases, and integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 97f01fc commit 00aac36

File tree

9 files changed

+385
-1
lines changed

9 files changed

+385
-1
lines changed

cmd/wl/cmd_fake_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type fakeWLCommonsStore struct {
1717
// Error injection fields
1818
InsertWantedErr error
1919
ClaimWantedErr error
20+
UnclaimWantedErr error
2021
SubmitCompletionErr error
2122
QueryWantedErr error
2223
QueryWantedDetailErr error
@@ -82,6 +83,26 @@ func (f *fakeWLCommonsStore) ClaimWanted(wantedID, rigHandle string) error {
8283
return nil
8384
}
8485

86+
func (f *fakeWLCommonsStore) UnclaimWanted(wantedID string) error {
87+
if f.UnclaimWantedErr != nil {
88+
return f.UnclaimWantedErr
89+
}
90+
91+
f.mu.Lock()
92+
defer f.mu.Unlock()
93+
94+
item, ok := f.items[wantedID]
95+
if !ok {
96+
return fmt.Errorf("wanted item %q not found", wantedID)
97+
}
98+
if item.Status != "claimed" {
99+
return fmt.Errorf("wanted item %q is not claimed (status: %s)", wantedID, item.Status)
100+
}
101+
item.Status = "open"
102+
item.ClaimedBy = ""
103+
return nil
104+
}
105+
85106
func (f *fakeWLCommonsStore) SubmitCompletion(completionID, wantedID, rigHandle, evidence string) error {
86107
if f.SubmitCompletionErr != nil {
87108
return f.SubmitCompletionErr

cmd/wl/cmd_unclaim.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/steveyegge/wasteland/internal/commons"
9+
"github.com/steveyegge/wasteland/internal/style"
10+
)
11+
12+
func newUnclaimCmd(stdout, stderr io.Writer) *cobra.Command {
13+
var noPush bool
14+
15+
cmd := &cobra.Command{
16+
Use: "unclaim <wanted-id>",
17+
Short: "Release a claimed wanted item back to open",
18+
Long: `Release a claimed wanted item, reverting it from 'claimed' to 'open'.
19+
20+
The item must be in 'claimed' status. Only the claimer or the poster can unclaim.
21+
22+
In wild-west mode the commit is auto-pushed to upstream and origin.
23+
Use --no-push to skip pushing (offline work).
24+
25+
Examples:
26+
wl unclaim w-abc123
27+
wl unclaim w-abc123 --no-push`,
28+
Args: cobra.ExactArgs(1),
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
return runUnclaim(cmd, stdout, stderr, args[0], noPush)
31+
},
32+
}
33+
34+
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
35+
36+
return cmd
37+
}
38+
39+
func runUnclaim(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, noPush bool) error {
40+
wlCfg, err := resolveWasteland(cmd)
41+
if err != nil {
42+
return fmt.Errorf("loading wasteland config: %w", err)
43+
}
44+
rigHandle := wlCfg.RigHandle
45+
46+
store := commons.NewWLCommons(wlCfg.LocalDir)
47+
item, err := unclaimWanted(store, wantedID, rigHandle)
48+
if err != nil {
49+
return err
50+
}
51+
52+
fmt.Fprintf(stdout, "%s Unclaimed %s\n", style.Bold.Render("✓"), wantedID)
53+
fmt.Fprintf(stdout, " Title: %s\n", item.Title)
54+
fmt.Fprintf(stdout, " Status: open\n")
55+
56+
if !noPush {
57+
_ = commons.PushWithSync(wlCfg.LocalDir, stdout)
58+
}
59+
60+
return nil
61+
}
62+
63+
// unclaimWanted contains the testable business logic for unclaiming a wanted item.
64+
func unclaimWanted(store commons.WLCommonsStore, wantedID, rigHandle string) (*commons.WantedItem, error) {
65+
item, err := store.QueryWanted(wantedID)
66+
if err != nil {
67+
return nil, fmt.Errorf("querying wanted item: %w", err)
68+
}
69+
70+
if item.Status != "claimed" {
71+
return nil, fmt.Errorf("wanted item %s is not claimed (status: %s)", wantedID, item.Status)
72+
}
73+
74+
if item.ClaimedBy != rigHandle && item.PostedBy != rigHandle {
75+
return nil, fmt.Errorf("only the claimer or poster can unclaim (claimed by %q, posted by %q)", item.ClaimedBy, item.PostedBy)
76+
}
77+
78+
if err := store.UnclaimWanted(wantedID); err != nil {
79+
return nil, fmt.Errorf("unclaiming wanted item: %w", err)
80+
}
81+
82+
return item, nil
83+
}

cmd/wl/cmd_unclaim_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/steveyegge/wasteland/internal/commons"
9+
)
10+
11+
func TestUnclaimWanted_Success(t *testing.T) {
12+
t.Parallel()
13+
store := newFakeWLCommonsStore()
14+
_ = store.InsertWanted(&commons.WantedItem{
15+
ID: "w-abc123",
16+
Title: "Fix auth bug",
17+
PostedBy: "poster-rig",
18+
})
19+
_ = store.ClaimWanted("w-abc123", "my-rig")
20+
21+
item, err := unclaimWanted(store, "w-abc123", "my-rig")
22+
if err != nil {
23+
t.Fatalf("unclaimWanted() error: %v", err)
24+
}
25+
if item.Title != "Fix auth bug" {
26+
t.Errorf("Title = %q, want %q", item.Title, "Fix auth bug")
27+
}
28+
29+
// Verify status was reverted in store.
30+
updated, _ := store.QueryWanted("w-abc123")
31+
if updated.Status != "open" {
32+
t.Errorf("Status = %q, want %q", updated.Status, "open")
33+
}
34+
if updated.ClaimedBy != "" {
35+
t.Errorf("ClaimedBy = %q, want empty", updated.ClaimedBy)
36+
}
37+
}
38+
39+
func TestUnclaimWanted_ByPoster(t *testing.T) {
40+
t.Parallel()
41+
store := newFakeWLCommonsStore()
42+
_ = store.InsertWanted(&commons.WantedItem{
43+
ID: "w-abc123",
44+
Title: "Fix auth bug",
45+
PostedBy: "poster-rig",
46+
})
47+
_ = store.ClaimWanted("w-abc123", "claimer-rig")
48+
49+
// Poster (not claimer) unclaims — should succeed.
50+
item, err := unclaimWanted(store, "w-abc123", "poster-rig")
51+
if err != nil {
52+
t.Fatalf("unclaimWanted() error: %v", err)
53+
}
54+
if item.Title != "Fix auth bug" {
55+
t.Errorf("Title = %q, want %q", item.Title, "Fix auth bug")
56+
}
57+
58+
updated, _ := store.QueryWanted("w-abc123")
59+
if updated.Status != "open" {
60+
t.Errorf("Status = %q, want %q", updated.Status, "open")
61+
}
62+
if updated.ClaimedBy != "" {
63+
t.Errorf("ClaimedBy = %q, want empty", updated.ClaimedBy)
64+
}
65+
}
66+
67+
func TestUnclaimWanted_NotClaimed(t *testing.T) {
68+
t.Parallel()
69+
store := newFakeWLCommonsStore()
70+
_ = store.InsertWanted(&commons.WantedItem{
71+
ID: "w-abc123",
72+
Title: "Fix auth bug",
73+
PostedBy: "poster-rig",
74+
})
75+
76+
_, err := unclaimWanted(store, "w-abc123", "my-rig")
77+
if err == nil {
78+
t.Fatal("unclaimWanted() expected error for non-claimed item")
79+
}
80+
if !strings.Contains(err.Error(), "not claimed") {
81+
t.Errorf("error = %q, want to contain 'not claimed'", err.Error())
82+
}
83+
}
84+
85+
func TestUnclaimWanted_NotFound(t *testing.T) {
86+
t.Parallel()
87+
store := newFakeWLCommonsStore()
88+
89+
_, err := unclaimWanted(store, "w-nonexistent", "my-rig")
90+
if err == nil {
91+
t.Fatal("unclaimWanted() expected error for missing item")
92+
}
93+
}
94+
95+
func TestUnclaimWanted_NotAuthorized(t *testing.T) {
96+
t.Parallel()
97+
store := newFakeWLCommonsStore()
98+
_ = store.InsertWanted(&commons.WantedItem{
99+
ID: "w-abc123",
100+
Title: "Fix auth bug",
101+
PostedBy: "poster-rig",
102+
})
103+
_ = store.ClaimWanted("w-abc123", "claimer-rig")
104+
105+
// Someone who is neither claimer nor poster tries to unclaim.
106+
_, err := unclaimWanted(store, "w-abc123", "random-rig")
107+
if err == nil {
108+
t.Fatal("unclaimWanted() expected error for unauthorized rig")
109+
}
110+
if !strings.Contains(err.Error(), "only the claimer or poster") {
111+
t.Errorf("error = %q, want to contain 'only the claimer or poster'", err.Error())
112+
}
113+
}
114+
115+
func TestUnclaimWanted_StoreError(t *testing.T) {
116+
t.Parallel()
117+
store := newFakeWLCommonsStore()
118+
store.UnclaimWantedErr = fmt.Errorf("unclaim store error")
119+
_ = store.InsertWanted(&commons.WantedItem{
120+
ID: "w-abc123",
121+
Title: "Fix auth bug",
122+
PostedBy: "poster-rig",
123+
})
124+
_ = store.ClaimWanted("w-abc123", "my-rig")
125+
126+
_, err := unclaimWanted(store, "w-abc123", "my-rig")
127+
if err == nil {
128+
t.Fatal("unclaimWanted() expected error when UnclaimWanted fails")
129+
}
130+
if !strings.Contains(err.Error(), "unclaim store error") {
131+
t.Errorf("error = %q, want to contain 'unclaim store error'", err.Error())
132+
}
133+
}

cmd/wl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
6666
newJoinCmd(stdout, stderr),
6767
newPostCmd(stdout, stderr),
6868
newClaimCmd(stdout, stderr),
69+
newUnclaimCmd(stdout, stderr),
6970
newDoneCmd(stdout, stderr),
7071
newAcceptCmd(stdout, stderr),
7172
newRejectCmd(stdout, stderr),

cmd/wl/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestSubcommandRegistration(t *testing.T) {
2828
var stdout, stderr bytes.Buffer
2929
root := newRootCmd(&stdout, &stderr)
3030

31-
expected := []string{"join", "post", "claim", "done", "accept", "reject", "update", "delete", "browse", "status", "sync", "leave", "list", "version"}
31+
expected := []string{"join", "post", "claim", "unclaim", "done", "accept", "reject", "update", "delete", "browse", "status", "sync", "leave", "list", "version"}
3232
for _, name := range expected {
3333
found := false
3434
for _, c := range root.Commands() {

cmd/wl/testdata/errors.txtar

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ stderr 'accepts 1 arg'
4646
! exec wl claim w-abc
4747
stderr 'not joined'
4848

49+
# unclaim with no args.
50+
! exec wl unclaim
51+
stderr 'accepts 1 arg'
52+
53+
# unclaim not joined.
54+
! exec wl unclaim w-abc
55+
stderr 'not joined'
56+
4957
# done with no args.
5058
! exec wl done
5159
stderr 'accepts 1 arg'

internal/commons/commons.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
type WLCommonsStore interface {
2020
InsertWanted(item *WantedItem) error
2121
ClaimWanted(wantedID, rigHandle string) error
22+
UnclaimWanted(wantedID string) error
2223
SubmitCompletion(completionID, wantedID, rigHandle, evidence string) error
2324
QueryWanted(wantedID string) (*WantedItem, error)
2425
QueryWantedDetail(wantedID string) (*WantedItem, error)
@@ -47,6 +48,11 @@ func (w *WLCommons) ClaimWanted(wantedID, rigHandle string) error {
4748
return ClaimWanted(w.dbDir, wantedID, rigHandle)
4849
}
4950

51+
// UnclaimWanted reverts a claimed wanted item to open.
52+
func (w *WLCommons) UnclaimWanted(wantedID string) error {
53+
return UnclaimWanted(w.dbDir, wantedID)
54+
}
55+
5056
// SubmitCompletion records completion evidence for a claimed wanted item.
5157
func (w *WLCommons) SubmitCompletion(completionID, wantedID, rigHandle, evidence string) error {
5258
return SubmitCompletion(w.dbDir, completionID, wantedID, rigHandle, evidence)
@@ -253,6 +259,25 @@ CALL DOLT_COMMIT('-m', 'wl claim: %s');
253259
return fmt.Errorf("claim failed: %w", err)
254260
}
255261

262+
// UnclaimWanted reverts a claimed wanted item to open, clearing claimed_by.
263+
// dbDir is the actual database directory.
264+
func UnclaimWanted(dbDir, wantedID string) error {
265+
script := fmt.Sprintf(`UPDATE wanted SET claimed_by=NULL, status='open', updated_at=NOW()
266+
WHERE id='%s' AND status='claimed';
267+
CALL DOLT_ADD('-A');
268+
CALL DOLT_COMMIT('-m', 'wl unclaim: %s');
269+
`, EscapeSQL(wantedID), EscapeSQL(wantedID))
270+
271+
err := doltSQLScript(dbDir, script)
272+
if err == nil {
273+
return nil
274+
}
275+
if isNothingToCommit(err) {
276+
return fmt.Errorf("wanted item %q is not claimed or does not exist", wantedID)
277+
}
278+
return fmt.Errorf("unclaim failed: %w", err)
279+
}
280+
256281
// SubmitCompletion inserts a completion record and updates the wanted status.
257282
// dbDir is the actual database directory.
258283
func SubmitCompletion(dbDir, completionID, wantedID, rigHandle, evidence string) error {

internal/commons/commons_fake_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type fakeWLCommonsStore struct {
1212

1313
InsertWantedErr error
1414
ClaimWantedErr error
15+
UnclaimWantedErr error
1516
SubmitCompletionErr error
1617
QueryWantedErr error
1718
}
@@ -62,6 +63,26 @@ func (f *fakeWLCommonsStore) ClaimWanted(wantedID, rigHandle string) error {
6263
return nil
6364
}
6465

66+
func (f *fakeWLCommonsStore) UnclaimWanted(wantedID string) error {
67+
if f.UnclaimWantedErr != nil {
68+
return f.UnclaimWantedErr
69+
}
70+
71+
f.mu.Lock()
72+
defer f.mu.Unlock()
73+
74+
item, ok := f.items[wantedID]
75+
if !ok {
76+
return fmt.Errorf("wanted item %q not found", wantedID)
77+
}
78+
if item.Status != "claimed" {
79+
return fmt.Errorf("wanted item %q is not claimed (status: %s)", wantedID, item.Status)
80+
}
81+
item.Status = "open"
82+
item.ClaimedBy = ""
83+
return nil
84+
}
85+
6586
func (f *fakeWLCommonsStore) SubmitCompletion(_, wantedID, rigHandle, _ string) error {
6687
if f.SubmitCompletionErr != nil {
6788
return f.SubmitCompletionErr

0 commit comments

Comments
 (0)