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:
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user