Skip to content

Commit 6ecd6c7

Browse files
julianknutsenclaude
andcommitted
Add wl accept, wl update, wl delete commands
Complete the wanted lifecycle: accept validates completions and issues reputation stamps, update edits fields on open items, delete withdraws open items by setting status=withdrawn. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a19e003 commit 6ecd6c7

12 files changed

Lines changed: 1104 additions & 5 deletions

cmd/wl/cmd_accept.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package main
2+
3+
import (
4+
"crypto/sha256"
5+
"fmt"
6+
"io"
7+
"strings"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/steveyegge/wasteland/internal/commons"
12+
"github.com/steveyegge/wasteland/internal/style"
13+
)
14+
15+
func newAcceptCmd(stdout, stderr io.Writer) *cobra.Command {
16+
var (
17+
quality int
18+
reliability int
19+
severity string
20+
skills string
21+
message string
22+
noPush bool
23+
)
24+
25+
cmd := &cobra.Command{
26+
Use: "accept <wanted-id>",
27+
Short: "Accept a completed wanted item and issue a stamp",
28+
Long: `Accept a completed wanted item by reviewing the work and issuing a reputation stamp.
29+
30+
The item must be in 'in_review' status. You cannot accept your own completion.
31+
32+
A stamp is created with quality and optional reliability ratings (1-5),
33+
severity (leaf/branch/root), and optional skill tags.
34+
35+
In wild-west mode the commit is auto-pushed to upstream and origin.
36+
Use --no-push to skip pushing (offline work).
37+
38+
Examples:
39+
wl accept w-abc123 --quality 4
40+
wl accept w-abc123 --quality 5 --reliability 4 --severity branch
41+
wl accept w-abc123 --quality 3 --skills "go,federation" --message "solid work"`,
42+
Args: cobra.ExactArgs(1),
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
return runAccept(cmd, stdout, stderr, args[0], quality, reliability, severity, skills, message, noPush)
45+
},
46+
}
47+
48+
cmd.Flags().IntVar(&quality, "quality", 0, "Quality rating 1-5 (required)")
49+
cmd.Flags().IntVar(&reliability, "reliability", 0, "Reliability rating 1-5 (defaults to quality)")
50+
cmd.Flags().StringVar(&severity, "severity", "leaf", "Severity: leaf, branch, root")
51+
cmd.Flags().StringVar(&skills, "skills", "", "Comma-separated skill tags")
52+
cmd.Flags().StringVar(&message, "message", "", "Freeform message")
53+
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
54+
_ = cmd.MarkFlagRequired("quality")
55+
56+
return cmd
57+
}
58+
59+
func runAccept(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, quality, reliability int, severity, skills, message string, noPush bool) error {
60+
if reliability == 0 {
61+
reliability = quality
62+
}
63+
64+
if err := validateAcceptInputs(quality, reliability, severity); err != nil {
65+
return err
66+
}
67+
68+
var skillTags []string
69+
if skills != "" {
70+
for _, s := range strings.Split(skills, ",") {
71+
s = strings.TrimSpace(s)
72+
if s != "" {
73+
skillTags = append(skillTags, s)
74+
}
75+
}
76+
}
77+
78+
wlCfg, err := resolveWasteland(cmd)
79+
if err != nil {
80+
return fmt.Errorf("loading wasteland config: %w", err)
81+
}
82+
rigHandle := wlCfg.RigHandle
83+
84+
store := commons.NewWLCommons(wlCfg.LocalDir)
85+
86+
stamp, err := acceptCompletion(store, wantedID, rigHandle, quality, reliability, severity, skillTags, message)
87+
if err != nil {
88+
return err
89+
}
90+
91+
fmt.Fprintf(stdout, "%s Accepted %s\n", style.Bold.Render("✓"), wantedID)
92+
fmt.Fprintf(stdout, " Stamp ID: %s\n", stamp.ID)
93+
fmt.Fprintf(stdout, " Quality: %d, Reliability: %d\n", stamp.Quality, stamp.Reliability)
94+
fmt.Fprintf(stdout, " Severity: %s\n", stamp.Severity)
95+
if len(stamp.SkillTags) > 0 {
96+
fmt.Fprintf(stdout, " Skills: %s\n", strings.Join(stamp.SkillTags, ", "))
97+
}
98+
if stamp.Message != "" {
99+
fmt.Fprintf(stdout, " Message: %s\n", stamp.Message)
100+
}
101+
fmt.Fprintf(stdout, " Status: completed\n")
102+
103+
if !noPush {
104+
_ = commons.PushWithSync(wlCfg.LocalDir, stdout)
105+
}
106+
107+
return nil
108+
}
109+
110+
// validateAcceptInputs validates quality, reliability, and severity values.
111+
func validateAcceptInputs(quality, reliability int, severity string) error {
112+
if quality < 1 || quality > 5 {
113+
return fmt.Errorf("invalid quality %d: must be 1-5", quality)
114+
}
115+
if reliability < 1 || reliability > 5 {
116+
return fmt.Errorf("invalid reliability %d: must be 1-5", reliability)
117+
}
118+
validSeverities := map[string]bool{
119+
"leaf": true, "branch": true, "root": true,
120+
}
121+
if !validSeverities[severity] {
122+
return fmt.Errorf("invalid severity %q: must be one of leaf, branch, root", severity)
123+
}
124+
return nil
125+
}
126+
127+
// acceptCompletion contains the testable business logic for accepting a completion.
128+
func acceptCompletion(store commons.WLCommonsStore, wantedID, rigHandle string, quality, reliability int, severity string, skillTags []string, message string) (*commons.Stamp, error) {
129+
item, err := store.QueryWanted(wantedID)
130+
if err != nil {
131+
return nil, fmt.Errorf("querying wanted item: %w", err)
132+
}
133+
134+
if item.Status != "in_review" {
135+
return nil, fmt.Errorf("wanted item %s is not in_review (status: %s)", wantedID, item.Status)
136+
}
137+
138+
completion, err := store.QueryCompletion(wantedID)
139+
if err != nil {
140+
return nil, fmt.Errorf("querying completion: %w", err)
141+
}
142+
143+
if completion.CompletedBy == rigHandle {
144+
return nil, fmt.Errorf("cannot accept your own completion")
145+
}
146+
147+
stampID := generateStampID(wantedID, rigHandle)
148+
stamp := &commons.Stamp{
149+
ID: stampID,
150+
Author: rigHandle,
151+
Subject: completion.CompletedBy,
152+
Quality: quality,
153+
Reliability: reliability,
154+
Severity: severity,
155+
ContextID: completion.ID,
156+
ContextType: "completion",
157+
SkillTags: skillTags,
158+
Message: message,
159+
}
160+
161+
if err := store.AcceptCompletion(wantedID, completion.ID, rigHandle, stamp); err != nil {
162+
return nil, fmt.Errorf("accepting completion: %w", err)
163+
}
164+
165+
return stamp, nil
166+
}
167+
168+
func generateStampID(wantedID, rigHandle string) string {
169+
now := time.Now().UTC().Format(time.RFC3339)
170+
h := sha256.Sum256([]byte(wantedID + "|" + rigHandle + "|" + now))
171+
return fmt.Sprintf("s-%x", h[:8])
172+
}

cmd/wl/cmd_accept_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/steveyegge/wasteland/internal/commons"
9+
)
10+
11+
func TestGenerateStampID_Format(t *testing.T) {
12+
t.Parallel()
13+
id := generateStampID("w-abc123", "my-rig")
14+
if !strings.HasPrefix(id, "s-") {
15+
t.Errorf("generateStampID() = %q, want prefix 's-'", id)
16+
}
17+
// "s-" + 16 hex chars = 18 chars total
18+
if len(id) != 18 {
19+
t.Errorf("generateStampID() length = %d, want 18", len(id))
20+
}
21+
hexPart := id[2:]
22+
for _, c := range hexPart {
23+
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
24+
t.Errorf("generateStampID() contains non-hex char %q in %q", string(c), id)
25+
}
26+
}
27+
}
28+
29+
func TestValidateAcceptInputs(t *testing.T) {
30+
t.Parallel()
31+
tests := []struct {
32+
name string
33+
quality int
34+
reliability int
35+
severity string
36+
wantErr string
37+
}{
38+
{"valid", 3, 4, "leaf", ""},
39+
{"quality too low", 0, 3, "leaf", "invalid quality"},
40+
{"quality too high", 6, 3, "leaf", "invalid quality"},
41+
{"reliability too low", 3, 0, "leaf", "invalid reliability"},
42+
{"reliability too high", 3, 6, "leaf", "invalid reliability"},
43+
{"bad severity", 3, 3, "bad", "invalid severity"},
44+
{"valid branch", 5, 5, "branch", ""},
45+
{"valid root", 1, 1, "root", ""},
46+
}
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
t.Parallel()
50+
err := validateAcceptInputs(tt.quality, tt.reliability, tt.severity)
51+
if tt.wantErr == "" {
52+
if err != nil {
53+
t.Errorf("validateAcceptInputs() unexpected error: %v", err)
54+
}
55+
} else {
56+
if err == nil {
57+
t.Fatalf("validateAcceptInputs() expected error containing %q", tt.wantErr)
58+
}
59+
if !strings.Contains(err.Error(), tt.wantErr) {
60+
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
61+
}
62+
}
63+
})
64+
}
65+
}
66+
67+
func TestAcceptCompletion_Success(t *testing.T) {
68+
t.Parallel()
69+
store := newFakeWLCommonsStore()
70+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug"})
71+
_ = store.ClaimWanted("w-abc", "worker-rig")
72+
_ = store.SubmitCompletion("c-test123", "w-abc", "worker-rig", "https://github.com/pr/1")
73+
74+
stamp, err := acceptCompletion(store, "w-abc", "reviewer-rig", 4, 3, "leaf", []string{"go", "auth"}, "solid work")
75+
if err != nil {
76+
t.Fatalf("acceptCompletion() error: %v", err)
77+
}
78+
79+
if stamp.Quality != 4 {
80+
t.Errorf("stamp.Quality = %d, want 4", stamp.Quality)
81+
}
82+
if stamp.Reliability != 3 {
83+
t.Errorf("stamp.Reliability = %d, want 3", stamp.Reliability)
84+
}
85+
if stamp.Severity != "leaf" {
86+
t.Errorf("stamp.Severity = %q, want %q", stamp.Severity, "leaf")
87+
}
88+
if stamp.Subject != "worker-rig" {
89+
t.Errorf("stamp.Subject = %q, want %q", stamp.Subject, "worker-rig")
90+
}
91+
if stamp.Author != "reviewer-rig" {
92+
t.Errorf("stamp.Author = %q, want %q", stamp.Author, "reviewer-rig")
93+
}
94+
if stamp.Message != "solid work" {
95+
t.Errorf("stamp.Message = %q, want %q", stamp.Message, "solid work")
96+
}
97+
if len(stamp.SkillTags) != 2 || stamp.SkillTags[0] != "go" || stamp.SkillTags[1] != "auth" {
98+
t.Errorf("stamp.SkillTags = %v, want [go auth]", stamp.SkillTags)
99+
}
100+
101+
item, _ := store.QueryWanted("w-abc")
102+
if item.Status != "completed" {
103+
t.Errorf("Status = %q, want %q", item.Status, "completed")
104+
}
105+
}
106+
107+
func TestAcceptCompletion_NotInReview(t *testing.T) {
108+
t.Parallel()
109+
store := newFakeWLCommonsStore()
110+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug"})
111+
112+
_, err := acceptCompletion(store, "w-abc", "reviewer-rig", 4, 3, "leaf", nil, "")
113+
if err == nil {
114+
t.Fatal("acceptCompletion() expected error for non-in_review item")
115+
}
116+
if !strings.Contains(err.Error(), "not in_review") {
117+
t.Errorf("error = %q, want to contain 'not in_review'", err.Error())
118+
}
119+
}
120+
121+
func TestAcceptCompletion_NotFound(t *testing.T) {
122+
t.Parallel()
123+
store := newFakeWLCommonsStore()
124+
125+
_, err := acceptCompletion(store, "w-nonexistent", "reviewer-rig", 4, 3, "leaf", nil, "")
126+
if err == nil {
127+
t.Fatal("acceptCompletion() expected error for missing item")
128+
}
129+
}
130+
131+
func TestAcceptCompletion_SelfAccept(t *testing.T) {
132+
t.Parallel()
133+
store := newFakeWLCommonsStore()
134+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug"})
135+
_ = store.ClaimWanted("w-abc", "my-rig")
136+
_ = store.SubmitCompletion("c-test123", "w-abc", "my-rig", "evidence")
137+
138+
_, err := acceptCompletion(store, "w-abc", "my-rig", 4, 3, "leaf", nil, "")
139+
if err == nil {
140+
t.Fatal("acceptCompletion() expected error for self-accept")
141+
}
142+
if !strings.Contains(err.Error(), "cannot accept your own completion") {
143+
t.Errorf("error = %q, want to contain 'cannot accept your own completion'", err.Error())
144+
}
145+
}
146+
147+
func TestAcceptCompletion_QueryCompletionError(t *testing.T) {
148+
t.Parallel()
149+
store := newFakeWLCommonsStore()
150+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug", Status: "in_review"})
151+
store.QueryCompletionErr = fmt.Errorf("completion query error")
152+
153+
_, err := acceptCompletion(store, "w-abc", "reviewer-rig", 4, 3, "leaf", nil, "")
154+
if err == nil {
155+
t.Fatal("acceptCompletion() expected error when QueryCompletion fails")
156+
}
157+
if !strings.Contains(err.Error(), "completion query error") {
158+
t.Errorf("error = %q, want to contain 'completion query error'", err.Error())
159+
}
160+
}
161+
162+
func TestAcceptCompletion_AcceptCompletionError(t *testing.T) {
163+
t.Parallel()
164+
store := newFakeWLCommonsStore()
165+
_ = store.InsertWanted(&commons.WantedItem{ID: "w-abc", Title: "Fix bug"})
166+
_ = store.ClaimWanted("w-abc", "worker-rig")
167+
_ = store.SubmitCompletion("c-test123", "w-abc", "worker-rig", "evidence")
168+
store.AcceptCompletionErr = fmt.Errorf("accept store error")
169+
170+
_, err := acceptCompletion(store, "w-abc", "reviewer-rig", 4, 3, "leaf", nil, "")
171+
if err == nil {
172+
t.Fatal("acceptCompletion() expected error when AcceptCompletion fails")
173+
}
174+
if !strings.Contains(err.Error(), "accept store error") {
175+
t.Errorf("error = %q, want to contain 'accept store error'", err.Error())
176+
}
177+
}

0 commit comments

Comments
 (0)