1 Commits

Author SHA1 Message Date
f145979684 fix(worklog): always send logged_date (required datetime) on worklog add
The backend's WorkLogCreate.logged_date is a REQUIRED datetime, but the CLI
only sent it when --date was passed, and then as a bare YYYY-MM-DD that
failed datetime parsing → `hf worklog add` always 422'd (missing, then
invalid). Default logged_date to now (RFC3339); anchor a bare --date
<yyyy-mm-dd> to start-of-day so it parses as a datetime.

Verified on sim: `hf worklog add --task <code> --hours <n> --desc ...`
(no --date) now succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:15:10 +01:00
2 changed files with 21 additions and 34 deletions

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -33,30 +32,11 @@ type milestoneProgressResponse struct {
Progress float64 `json:"progress"` 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>`. // RunMilestoneList implements `hf milestone list --project <project-code>`.
func RunMilestoneList(args []string, tokenFlag string) { func RunMilestoneList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag) token := ResolveToken(tokenFlag)
query, project := "", "" query := ""
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--project": case "--project":
@@ -64,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
output.Error("--project requires a value") output.Error("--project requires a value")
} }
i++ i++
project = args[i] query = appendQuery(query, "project_code", args[i])
case "--status": case "--status":
if i+1 >= len(args) { if i+1 >= len(args) {
output.Error("--status requires a value") output.Error("--status requires a value")
@@ -86,11 +66,8 @@ func RunMilestoneList(args []string, tokenFlag string) {
if err != nil { if err != nil {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
if project == "" {
output.Error("--project is required (milestones are listed per project)")
}
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
path := "/projects/" + project + "/milestones" path := "/milestones"
if query != "" { if query != "" {
path += "?" + query path += "?" + query
} }
@@ -133,7 +110,7 @@ func RunMilestoneGet(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode) data, err := c.Get("/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to get milestone: %v", err) output.Errorf("failed to get milestone: %v", err)
} }
@@ -219,7 +196,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
payload["description"] = desc payload["description"] = desc
} }
if due != "" { if due != "" {
payload["due_date"] = toMilestoneDateTime(due) payload["due_date"] = due
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@@ -232,7 +209,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Post("/projects/"+project+"/milestones", bytes.NewReader(body)) data, err := c.Post("/milestones", bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to create milestone: %v", err) output.Errorf("failed to create milestone: %v", err)
} }
@@ -284,7 +261,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Error("--due requires a value") output.Error("--due requires a value")
} }
i++ i++
payload["due_date"] = toMilestoneDateTime(args[i]) payload["due_date"] = args[i]
default: default:
output.Errorf("unknown flag: %s", args[i]) output.Errorf("unknown flag: %s", args[i])
} }
@@ -304,7 +281,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/projects/"+milestoneProject(milestoneCode)+"/milestones/"+milestoneCode, bytes.NewReader(body)) _, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to update milestone: %v", err) output.Errorf("failed to update milestone: %v", err)
} }
@@ -320,7 +297,7 @@ func RunMilestoneDelete(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode) _, err = c.Delete("/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to delete milestone: %v", err) output.Errorf("failed to delete milestone: %v", err)
} }
@@ -335,7 +312,7 @@ func RunMilestoneProgress(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode + "/progress") data, err := c.Get("/milestones/" + milestoneCode + "/progress")
if err != nil { if err != nil {
output.Errorf("failed to get milestone progress: %v", err) output.Errorf("failed to get milestone progress: %v", err)
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
) )
@@ -35,9 +36,18 @@ func RunWorklogAdd(taskCode string, hours float64, desc, date, tokenFlag string)
if desc != "" { if desc != "" {
payload["description"] = desc payload["description"] = desc
} }
// logged_date is a REQUIRED datetime on the backend. Default to now; if
// the operator passed --date <yyyy-mm-dd>, anchor it to start-of-day so a
// bare date still parses as a datetime.
loggedDate := time.Now().UTC().Format(time.RFC3339)
if date != "" { if date != "" {
payload["logged_date"] = date if len(date) == 10 { // bare YYYY-MM-DD
loggedDate = date + "T00:00:00Z"
} else {
loggedDate = date
} }
}
payload["logged_date"] = loggedDate
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {