Skip to content

Commit 121fa6b

Browse files
julianknutsenclaude
andcommitted
Add wl close command for solo maintainer housekeeping
Lets the poster mark an in_review item as completed without issuing a reputation stamp. Solves the solo maintainer problem where accept requires a different rig to have completed the work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e24fc4 commit 121fa6b

7 files changed

Lines changed: 277 additions & 2 deletions

File tree

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,32 @@ Maintainers with push access to upstream can skip forking:
7272
wl join --direct [--signed] # clone upstream directly, no fork
7373
```
7474

75+
### Solo maintainer workflow
76+
77+
If you're bootstrapping a wasteland, you can work your own wanted board:
78+
79+
```bash
80+
wl post --title "Set up CI" --type feature
81+
wl claim w-abc123
82+
wl done w-abc123 --evidence "https://github.com/org/repo/pull/1"
83+
wl close w-abc123
84+
```
85+
86+
The item moves through `open → claimed → in_review → completed`.
87+
Since `accept` requires a different rig to have completed the work
88+
(you can't stamp your own completion), use `wl close` to mark your
89+
own items as completed without issuing a reputation stamp. This is
90+
housekeeping, not reputation — stamps must come from someone else.
91+
7592
## Workflow
7693

7794
A wanted item moves through this lifecycle:
7895

7996
```
8097
open ──→ claimed ──→ in_review ──→ completed
8198
│ │ ↑
82-
│ ↓
83-
│ (unclaim → open) (accept + stamp)
99+
│ ↓ ├── accept (+ stamp)
100+
│ (unclaim → open) └── close (no stamp)
84101
85102
86103
withdrawn
@@ -290,6 +307,7 @@ Config and data follow XDG conventions:
290307
| `wl done <id>` | Submit completion evidence | `--evidence` (required), `--no-push` |
291308
| `wl accept <id>` | Accept and issue a stamp | `--quality` (required), `--reliability`, `--severity`, `--skills` |
292309
| `wl reject <id>` | Reject back to claimed | `--reason`, `--no-push` |
310+
| `wl close <id>` | Close in_review item (no stamp) | `--no-push` |
293311
| `wl status <id>` | Show full item details | |
294312
| `wl update <id>` | Update an open item | `--title`, `--priority`, `--effort`, `--type`, `--tags`, `--project` |
295313
| `wl unclaim <id>` | Release back to open | `--no-push` |

cmd/wl/cmd_close.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/julianknutsen/wasteland/internal/commons"
8+
"github.com/julianknutsen/wasteland/internal/style"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newCloseCmd(stdout, stderr io.Writer) *cobra.Command {
13+
var noPush bool
14+
15+
cmd := &cobra.Command{
16+
Use: "close <wanted-id>",
17+
Short: "Close an in_review item as completed (no stamp)",
18+
Long: `Close an in_review wanted item by marking it as completed without issuing
19+
a reputation stamp. This is housekeeping for solo maintainers who posted,
20+
claimed, and completed their own work.
21+
22+
The item must be in 'in_review' status and only the poster can close it.
23+
24+
In wild-west mode the commit is auto-pushed to upstream and origin.
25+
Use --no-push to skip pushing (offline work).
26+
27+
Examples:
28+
wl close w-abc123`,
29+
Args: cobra.ExactArgs(1),
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
return runClose(cmd, stdout, stderr, args[0], noPush)
32+
},
33+
}
34+
35+
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
36+
37+
return cmd
38+
}
39+
40+
func runClose(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, noPush bool) error {
41+
wlCfg, err := resolveWasteland(cmd)
42+
if err != nil {
43+
return fmt.Errorf("loading wasteland config: %w", err)
44+
}
45+
rigHandle := wlCfg.RigHandle
46+
47+
mc := newMutationContext(wlCfg, wantedID, noPush, stdout)
48+
cleanup, err := mc.Setup()
49+
if err != nil {
50+
return err
51+
}
52+
defer cleanup()
53+
54+
store := openStore(wlCfg.LocalDir, wlCfg.Signing, wlCfg.HopURI)
55+
56+
if err := closeWanted(store, wantedID, rigHandle); err != nil {
57+
return err
58+
}
59+
60+
fmt.Fprintf(stdout, "%s Closed %s\n", style.Bold.Render("✓"), wantedID)
61+
fmt.Fprintf(stdout, " Status: completed\n")
62+
if mc.BranchName() != "" {
63+
fmt.Fprintf(stdout, " Branch: %s\n", mc.BranchName())
64+
}
65+
66+
mc.Push()
67+
68+
return nil
69+
}
70+
71+
// closeWanted contains the testable business logic for closing a wanted item.
72+
func closeWanted(store commons.WLCommonsStore, wantedID, rigHandle string) error {
73+
item, err := store.QueryWanted(wantedID)
74+
if err != nil {
75+
return fmt.Errorf("querying wanted item: %w", err)
76+
}
77+
78+
if item.Status != "in_review" {
79+
return fmt.Errorf("wanted item %s is not in_review (status: %s)", wantedID, item.Status)
80+
}
81+
82+
if item.PostedBy != rigHandle {
83+
return fmt.Errorf("only the poster can close (posted by %q)", item.PostedBy)
84+
}
85+
86+
if err := store.CloseWanted(wantedID); err != nil {
87+
return fmt.Errorf("closing wanted item: %w", err)
88+
}
89+
90+
return nil
91+
}

cmd/wl/cmd_close_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/julianknutsen/wasteland/internal/commons"
9+
)
10+
11+
func TestCloseWanted_Success(t *testing.T) {
12+
t.Parallel()
13+
store := newFakeWLCommonsStore()
14+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug", PostedBy: "my-rig"})
15+
_ = store.ClaimWanted("w-abc", "my-rig")
16+
_ = store.SubmitCompletion("c-test123", "w-abc", "my-rig", "https://github.com/pr/1")
17+
18+
err := closeWanted(store, "w-abc", "my-rig")
19+
if err != nil {
20+
t.Fatalf("closeWanted() error: %v", err)
21+
}
22+
23+
item, _ := store.QueryWanted("w-abc")
24+
if item.Status != "completed" {
25+
t.Errorf("Status = %q, want %q", item.Status, "completed")
26+
}
27+
}
28+
29+
func TestCloseWanted_NotInReview(t *testing.T) {
30+
t.Parallel()
31+
tests := []struct {
32+
name string
33+
status string
34+
}{
35+
{"open item", ""},
36+
{"claimed item", "claimed"},
37+
}
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
t.Parallel()
41+
store := newFakeWLCommonsStore()
42+
item := &commons.WantedItem{ID: "w-abc", Title: "Fix bug", PostedBy: "my-rig"}
43+
if tt.status != "" {
44+
item.Status = tt.status
45+
}
46+
_ = store.InsertWanted(item)
47+
if tt.status == "claimed" {
48+
_ = store.ClaimWanted("w-abc", "my-rig")
49+
}
50+
51+
err := closeWanted(store, "w-abc", "my-rig")
52+
if err == nil {
53+
t.Fatal("closeWanted() expected error for non-in_review item")
54+
}
55+
if !strings.Contains(err.Error(), "not in_review") {
56+
t.Errorf("error = %q, want to contain 'not in_review'", err.Error())
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestCloseWanted_WrongPoster(t *testing.T) {
63+
t.Parallel()
64+
store := newFakeWLCommonsStore()
65+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug", PostedBy: "poster-rig"})
66+
_ = store.ClaimWanted("w-abc", "worker-rig")
67+
_ = store.SubmitCompletion("c-test123", "w-abc", "worker-rig", "evidence")
68+
69+
err := closeWanted(store, "w-abc", "other-rig")
70+
if err == nil {
71+
t.Fatal("closeWanted() expected error for non-poster")
72+
}
73+
if !strings.Contains(err.Error(), "only the poster can close") {
74+
t.Errorf("error = %q, want to contain 'only the poster can close'", err.Error())
75+
}
76+
}
77+
78+
func TestCloseWanted_NotFound(t *testing.T) {
79+
t.Parallel()
80+
store := newFakeWLCommonsStore()
81+
82+
err := closeWanted(store, "w-nonexistent", "my-rig")
83+
if err == nil {
84+
t.Fatal("closeWanted() expected error for missing item")
85+
}
86+
}
87+
88+
func TestCloseWanted_StoreError(t *testing.T) {
89+
t.Parallel()
90+
store := newFakeWLCommonsStore()
91+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug", Status: "in_review", PostedBy: "my-rig"})
92+
store.CloseWantedErr = fmt.Errorf("close store error")
93+
94+
err := closeWanted(store, "w-abc", "my-rig")
95+
if err == nil {
96+
t.Fatal("closeWanted() expected error when CloseWanted fails")
97+
}
98+
if !strings.Contains(err.Error(), "close store error") {
99+
t.Errorf("error = %q, want to contain 'close store error'", err.Error())
100+
}
101+
}

cmd/wl/cmd_fake_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type fakeWLCommonsStore struct {
2525
QueryStampErr error
2626
AcceptCompletionErr error
2727
RejectCompletionErr error
28+
CloseWantedErr error
2829
UpdateWantedErr error
2930
DeleteWantedErr error
3031
}
@@ -234,6 +235,25 @@ func (f *fakeWLCommonsStore) RejectCompletion(wantedID, _, _ string) error {
234235
return nil
235236
}
236237

238+
func (f *fakeWLCommonsStore) CloseWanted(wantedID string) error {
239+
if f.CloseWantedErr != nil {
240+
return f.CloseWantedErr
241+
}
242+
243+
f.mu.Lock()
244+
defer f.mu.Unlock()
245+
246+
item, ok := f.items[wantedID]
247+
if !ok {
248+
return fmt.Errorf("wanted item %q not found", wantedID)
249+
}
250+
if item.Status != "in_review" {
251+
return fmt.Errorf("wanted item %q is not in_review (status: %s)", wantedID, item.Status)
252+
}
253+
item.Status = "completed"
254+
return nil
255+
}
256+
237257
func (f *fakeWLCommonsStore) UpdateWanted(wantedID string, fields *commons.WantedUpdate) error {
238258
if f.UpdateWantedErr != nil {
239259
return f.UpdateWantedErr

cmd/wl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
7171
newDoneCmd(stdout, stderr),
7272
newAcceptCmd(stdout, stderr),
7373
newRejectCmd(stdout, stderr),
74+
newCloseCmd(stdout, stderr),
7475
newUpdateCmd(stdout, stderr),
7576
newDeleteCmd(stdout, stderr),
7677
newBrowseCmd(stdout, stderr),

internal/commons/commons.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type WLCommonsStore interface {
2727
QueryStamp(stampID string) (*Stamp, error)
2828
AcceptCompletion(wantedID, completionID, rigHandle string, stamp *Stamp) error
2929
RejectCompletion(wantedID, rigHandle, reason string) error
30+
CloseWanted(wantedID string) error
3031
UpdateWanted(wantedID string, fields *WantedUpdate) error
3132
DeleteWanted(wantedID string) error
3233
}
@@ -103,6 +104,11 @@ func (w *WLCommons) RejectCompletion(wantedID, rigHandle, reason string) error {
103104
return RejectCompletion(w.dbDir, wantedID, rigHandle, reason, w.signed)
104105
}
105106

107+
// CloseWanted marks an in_review item as completed without a stamp.
108+
func (w *WLCommons) CloseWanted(wantedID string) error {
109+
return CloseWanted(w.dbDir, wantedID, w.signed)
110+
}
111+
106112
// DeleteWanted soft-deletes a wanted item by setting status=withdrawn.
107113
func (w *WLCommons) DeleteWanted(wantedID string) error {
108114
return DeleteWanted(w.dbDir, wantedID, w.signed)
@@ -606,6 +612,24 @@ func UpdateWanted(dbDir, wantedID string, fields *WantedUpdate, signed bool) err
606612
return fmt.Errorf("update failed: %w", err)
607613
}
608614

615+
// CloseWanted marks an in_review wanted item as completed without issuing a
616+
// stamp. This is housekeeping for solo maintainers who completed their own work.
617+
// dbDir is the actual database directory.
618+
func CloseWanted(dbDir, wantedID string, signed bool) error {
619+
script := fmt.Sprintf("UPDATE wanted SET status='completed', updated_at=NOW() WHERE id='%s' AND status='in_review';\nCALL DOLT_ADD('-A');\n",
620+
EscapeSQL(wantedID))
621+
script += commitSQL("wl close: "+wantedID, signed)
622+
623+
err := doltSQLScript(dbDir, script)
624+
if err == nil {
625+
return nil
626+
}
627+
if isNothingToCommit(err) {
628+
return fmt.Errorf("wanted item %q is not in_review or does not exist", wantedID)
629+
}
630+
return fmt.Errorf("close failed: %w", err)
631+
}
632+
609633
// formatTagsJSON formats a string slice as a JSON array SQL literal.
610634
func formatTagsJSON(tags []string) string {
611635
if len(tags) == 0 {

internal/commons/commons_fake_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type fakeWLCommonsStore struct {
1515
UnclaimWantedErr error
1616
SubmitCompletionErr error
1717
QueryWantedErr error
18+
CloseWantedErr error
1819
}
1920

2021
func (f *fakeWLCommonsStore) InsertWanted(item *WantedItem) error {
@@ -133,6 +134,25 @@ func (f *fakeWLCommonsStore) QueryStamp(_ string) (*Stamp, error) {
133134
return nil, fmt.Errorf("not implemented in commons fake")
134135
}
135136

137+
func (f *fakeWLCommonsStore) CloseWanted(wantedID string) error {
138+
if f.CloseWantedErr != nil {
139+
return f.CloseWantedErr
140+
}
141+
142+
f.mu.Lock()
143+
defer f.mu.Unlock()
144+
145+
item, ok := f.items[wantedID]
146+
if !ok {
147+
return fmt.Errorf("wanted item %q not found", wantedID)
148+
}
149+
if item.Status != "in_review" {
150+
return fmt.Errorf("wanted item %q is not in_review (status: %s)", wantedID, item.Status)
151+
}
152+
item.Status = "completed"
153+
return nil
154+
}
155+
136156
func (f *fakeWLCommonsStore) AcceptCompletion(_, _, _ string, _ *Stamp) error {
137157
return fmt.Errorf("not implemented in commons fake")
138158
}

0 commit comments

Comments
 (0)