From fdf1ba1b17be063d95bd01d6e11e43b5378ced69 Mon Sep 17 00:00:00 2001 From: hzhang Date: Wed, 10 Jun 2026 12:56:58 +0100 Subject: [PATCH] fix(milestone): use nested /projects/{project}/milestones routes + datetime due MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract bugs broke `hf milestone *` against the backend: - The backend mounts milestones at prefix /projects/{project_id}/milestones (nested), but the CLI used flat /milestones, /milestones/, etc. → every milestone create/get/update/delete/progress/list 404'd. Switch to the nested routes: list/create take --project; get/update/delete/progress derive the project from the milestone code (PFIXTU:00001 → PFIXTU) via a new milestoneProject() helper. list now requires --project. - due_date is a REQUIRED datetime on the backend, but --due was sent date-only → 422 datetime_parsing. Anchor a bare date to start-of-day (toMilestoneDateTime), same as the worklog logged_date fix. Verified on sim: milestone create/list/get/progress all succeed. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/milestone.go | 43 ++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/internal/commands/milestone.go b/internal/commands/milestone.go index 69cbe70..6fe07a7 100644 --- a/internal/commands/milestone.go +++ b/internal/commands/milestone.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" @@ -32,11 +33,30 @@ type milestoneProgressResponse struct { Progress float64 `json:"progress"` } +// milestoneProject extracts the project code from a milestone code +// (e.g. "PFIXTU:00001" -> "PFIXTU"); milestones are nested under their +// project in the API (/projects/{project}/milestones/{code}). +func milestoneProject(code string) string { + if i := strings.IndexByte(code, ':'); i >= 0 { + return code[:i] + } + return code +} + +// toMilestoneDateTime anchors a bare YYYY-MM-DD due date to a datetime, since +// the backend's due_date field requires a full datetime. +func toMilestoneDateTime(d string) string { + if len(d) == 10 { + return d + "T00:00:00Z" + } + return d +} + // RunMilestoneList implements `hf milestone list --project `. func RunMilestoneList(args []string, tokenFlag string) { token := ResolveToken(tokenFlag) - query := "" + query, project := "", "" for i := 0; i < len(args); i++ { switch args[i] { case "--project": @@ -44,7 +64,7 @@ func RunMilestoneList(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project_code", args[i]) + project = args[i] case "--status": if i+1 >= len(args) { output.Error("--status requires a value") @@ -66,8 +86,11 @@ func RunMilestoneList(args []string, tokenFlag string) { if err != nil { output.Errorf("config error: %v", err) } + if project == "" { + output.Error("--project is required (milestones are listed per project)") + } c := client.New(cfg.BaseURL, token) - path := "/milestones" + path := "/projects/" + project + "/milestones" if query != "" { path += "?" + query } @@ -110,7 +133,7 @@ func RunMilestoneGet(milestoneCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/milestones/" + milestoneCode) + data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode) if err != nil { output.Errorf("failed to get milestone: %v", err) } @@ -196,7 +219,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) { payload["description"] = desc } if due != "" { - payload["due_date"] = due + payload["due_date"] = toMilestoneDateTime(due) } body, err := json.Marshal(payload) @@ -209,7 +232,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/milestones", bytes.NewReader(body)) + data, err := c.Post("/projects/"+project+"/milestones", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create milestone: %v", err) } @@ -261,7 +284,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) { output.Error("--due requires a value") } i++ - payload["due_date"] = args[i] + payload["due_date"] = toMilestoneDateTime(args[i]) default: output.Errorf("unknown flag: %s", args[i]) } @@ -281,7 +304,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body)) + _, err = c.Patch("/projects/"+milestoneProject(milestoneCode)+"/milestones/"+milestoneCode, bytes.NewReader(body)) if err != nil { output.Errorf("failed to update milestone: %v", err) } @@ -297,7 +320,7 @@ func RunMilestoneDelete(milestoneCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/milestones/" + milestoneCode) + _, err = c.Delete("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode) if err != nil { output.Errorf("failed to delete milestone: %v", err) } @@ -312,7 +335,7 @@ func RunMilestoneProgress(milestoneCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/milestones/" + milestoneCode + "/progress") + data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode + "/progress") if err != nil { output.Errorf("failed to get milestone progress: %v", err) }