fix(milestone): use nested /projects/{project}/milestones routes + datetime due
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/<code>, 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 <yyyy-mm-dd>
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <project-code>`.
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user