feat(cli): mred version update subcommand

`mred version update ID [--status open|closed] [--due YYYY-MM-DD]
[--name NAME]` flips milestone status, dates, and names; at least one
flag is required (usage error otherwise) and only provided flags are
sent. Text mode prints "updated version #N", -o json re-fetches and
emits the stored version, exit codes follow the 0/1/2 convention.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-29 08:12:16 -05:00
parent 26824c3715
commit 37118f0db9
3 changed files with 164 additions and 1 deletions
+1
View File
@@ -31,6 +31,7 @@ Usage:
[--desc FILE|-] [--note TEXT] [-o json] [--desc FILE|-] [--note TEXT] [-o json]
mred version list -p PROJECT [-o json] mred version list -p PROJECT [-o json]
mred version create -p PROJECT -n NAME [--due DATE] [--status open|closed] [-o json] mred version create -p PROJECT -n NAME [--due DATE] [--status open|closed] [-o json]
mred version update ID [--status open|closed] [--due DATE] [--name NAME] [-o json]
mred category list -p PROJECT [-o json] mred category list -p PROJECT [-o json]
mred category create -p PROJECT -n NAME [-o json] mred category create -p PROJECT -n NAME [-o json]
mred relation create FROM TO --type blocks|relates [-o json] mred relation create FROM TO --type blocks|relates [-o json]
+118
View File
@@ -255,6 +255,124 @@ func TestVersionCommands(t *testing.T) {
} }
} }
func TestVersionUpdate(t *testing.T) {
tests := []struct {
name string
args []string // ID placeholder "@" replaced with the seeded id
wantOut string // substring expected on stdout
wantVer fakeredmine.Version
wantCode int
}{
{
name: "status flip",
args: []string{"@", "--status", "closed"},
wantOut: "updated version #",
wantVer: fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "closed"},
},
{
name: "due change",
args: []string{"@", "--due", "2026-09-15"},
wantVer: fakeredmine.Version{Name: "Beta", DueDate: "2026-09-15", Status: "open"},
},
{
name: "name change",
args: []string{"@", "--name", "Beta 2"},
wantVer: fakeredmine.Version{Name: "Beta 2", DueDate: "2026-08-31", Status: "open"},
},
{
name: "combined flags",
args: []string{"@", "--status", "closed", "--due", "2026-09-15", "--name", "Gamma"},
wantVer: fakeredmine.Version{Name: "Gamma", DueDate: "2026-09-15", Status: "closed"},
},
{
name: "no flags is a usage error",
args: []string{"@"},
wantCode: 1,
},
{
name: "bad status is a usage error",
args: []string{"@", "--status", "deferred"},
wantCode: 1,
},
{
name: "non-numeric id is a usage error",
args: []string{"beta", "--status", "closed"},
wantCode: 1,
},
{
name: "unknown version id is exit 2",
args: []string{"424242", "--status", "closed"},
wantCode: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := boot(t)
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
args := make([]string, len(tt.args))
for i, a := range tt.args {
if a == "@" {
args[i] = itoa(id)
} else {
args[i] = a
}
}
out, errb, code := run(t, append([]string{"version", "update"}, args...)...)
wantCode := tt.wantCode
if wantCode == 0 {
if !strings.Contains(out, tt.wantOut) || !strings.Contains(out, itoa(id)) {
t.Errorf("out = %q, want %q + id %d", out, tt.wantOut, id)
}
got, ok := srv.Version(id)
if !ok {
t.Fatalf("version %d vanished", id)
}
if got.Name != tt.wantVer.Name || got.DueDate != tt.wantVer.DueDate || got.Status != tt.wantVer.Status {
t.Errorf("stored = %+v, want %+v", got, tt.wantVer)
}
} else {
if out != "" {
t.Errorf("usage error wrote to stdout: %q", out)
}
if errb == "" {
t.Errorf("no diagnostic on stderr")
}
if tt.wantCode == 2 && !strings.Contains(errb, "http 404") {
t.Errorf("stderr = %q, want http 404", errb)
}
}
if code != wantCode {
t.Fatalf("code = %d, want %d (stderr %q)", code, wantCode, errb)
}
})
}
}
func TestVersionUpdateJSONRefetches(t *testing.T) {
srv := boot(t)
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
out, errb, code := run(t, "version", "update", itoa(id), "--status", "closed", "-o", "json")
if code != 0 {
t.Fatalf("code = %d, stderr = %q", code, errb)
}
var got struct {
Version struct {
ID int `json:"id"`
Name string `json:"name"`
DueDate string `json:"due_date"`
Status string `json:"status"`
} `json:"version"`
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("stdout not json: %v (%q)", err, out)
}
v := got.Version
if v.ID != id || v.Name != "Beta" || v.DueDate != "2026-08-31" || v.Status != "closed" {
t.Errorf("refetched version = %+v", v)
}
}
func TestCategoryCommands(t *testing.T) { func TestCategoryCommands(t *testing.T) {
boot(t) boot(t)
_, errb, code := run(t, "category", "create", "-p", "MOPAC", "-n", "Secrets") _, errb, code := run(t, "category", "create", "-p", "MOPAC", "-n", "Secrets")
+45 -1
View File
@@ -356,7 +356,7 @@ func readDesc(path string) (string, error) {
func cmdVersion(args []string, stdout, stderr io.Writer) int { func cmdVersion(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 { if len(args) == 0 {
return usageErr(stderr, "version: expected list|create (see mred help)") return usageErr(stderr, "version: expected list|create|update (see mred help)")
} }
sub, rest := args[0], args[1:] sub, rest := args[0], args[1:]
switch sub { switch sub {
@@ -364,6 +364,8 @@ func cmdVersion(args []string, stdout, stderr io.Writer) int {
return cmdVersionList(rest, stdout, stderr) return cmdVersionList(rest, stdout, stderr)
case "create": case "create":
return cmdVersionCreate(rest, stdout, stderr) return cmdVersionCreate(rest, stdout, stderr)
case "update":
return cmdVersionUpdate(rest, stdout, stderr)
default: default:
return usageErr(stderr, "version: unknown subcommand %q", sub) return usageErr(stderr, "version: unknown subcommand %q", sub)
} }
@@ -432,6 +434,48 @@ func dueSuffix(due string) string {
return "due " + due return "due " + due
} }
func cmdVersionUpdate(args []string, stdout, stderr io.Writer) int {
fs, cfgPath, out := newFlags("version update", stderr)
status := fs.String("status", "", "open|closed")
due := fs.String("due", "", "due date YYYY-MM-DD")
name := fs.String("name", "", "new version name")
pos, perr := parseArgs(fs, args)
if perr != nil || len(pos) != 1 {
return usageErr(stderr, "version update: usage: mred version update ID [--status open|closed] [--due DATE] [--name NAME]")
}
id, err := strconv.Atoi(pos[0])
if err != nil {
return usageErr(stderr, "version update: ID must be numeric, got %q", pos[0])
}
if *status == "" && *due == "" && *name == "" {
return usageErr(stderr, "version update: nothing to update: pass at least one of --status, --due, --name")
}
if *status != "" && *status != "open" && *status != "closed" {
return usageErr(stderr, "version update: --status must be open|closed")
}
c, err := client(*cfgPath)
if err != nil {
return usageErr(stderr, "%v", err)
}
// The server answers 204 with an empty body; UpdateVersion never
// parses it. -o json re-fetches the version afterwards.
if err := c.UpdateVersion(context.Background(), id, redmine.VersionParams{
Name: *name, DueDate: *due, Status: *status,
}); err != nil {
return apiErr(stderr, err)
}
if *out == "json" {
v, err := c.GetVersion(context.Background(), id)
if err != nil {
return apiErr(stderr, err)
}
emitJSON(stdout, map[string]any{"version": v})
return 0
}
fmt.Fprintf(stdout, "updated version #%d\n", id)
return 0
}
// --- category ----------------------------------------------------------------- // --- category -----------------------------------------------------------------
func cmdCategory(args []string, stdout, stderr io.Writer) int { func cmdCategory(args []string, stdout, stderr io.Writer) int {