diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e7ab2d4..15d6b75 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -31,6 +31,7 @@ Usage: [--desc FILE|-] [--note TEXT] [-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 update ID [--status open|closed] [--due DATE] [--name NAME] [-o json] mred category list -p PROJECT [-o json] mred category create -p PROJECT -n NAME [-o json] mred relation create FROM TO --type blocks|relates [-o json] diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 50a2909..9960a87 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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) { boot(t) _, errb, code := run(t, "category", "create", "-p", "MOPAC", "-n", "Secrets") diff --git a/internal/cli/commands.go b/internal/cli/commands.go index afa1e41..d8f029c 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -356,7 +356,7 @@ func readDesc(path string) (string, error) { func cmdVersion(args []string, stdout, stderr io.Writer) int { 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:] switch sub { @@ -364,6 +364,8 @@ func cmdVersion(args []string, stdout, stderr io.Writer) int { return cmdVersionList(rest, stdout, stderr) case "create": return cmdVersionCreate(rest, stdout, stderr) + case "update": + return cmdVersionUpdate(rest, stdout, stderr) default: return usageErr(stderr, "version: unknown subcommand %q", sub) } @@ -432,6 +434,48 @@ func dueSuffix(due string) string { 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 ----------------------------------------------------------------- func cmdCategory(args []string, stdout, stderr io.Writer) int {