From 9b3edc0ede4a3ba84c121a1c69fa5c1bd1b9b995 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 17:11:20 +0000 Subject: [PATCH] Add comment and worklog CLI commands --- README.md | 3 +- cmd/hf/main.go | 132 ++++++++++++++++++++++++++++++++++ internal/commands/comment.go | 136 +++++++++++++++++++++++++++++++++++ internal/commands/worklog.go | 124 ++++++++++++++++++++++++++++++++ internal/help/leaf.go | 4 ++ internal/help/surface.go | 16 +++++ 6 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 internal/commands/comment.go create mode 100644 internal/commands/worklog.go diff --git a/README.md b/README.md index befc87a..9c944be 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,12 @@ internal/ - `hf meeting` — list, get, create, update, attend, delete - `hf support` — list, get, create, update, take, transition, delete - `hf propose` — list, get, create, update, accept, reject, reopen +- `hf comment` — add, list +- `hf worklog` — add, list - `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke) ### Pending - Backend code-based endpoint support (some commands still use id-based API routes) -- Comment and worklog commands - Cross-platform binary packaging / release pipeline - Integration tests diff --git a/cmd/hf/main.go b/cmd/hf/main.go index cb0b04a..32bbf79 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -190,6 +190,12 @@ func handleGroup(group help.Group, args []string) { case "propose": handleProposeCommand(sub.Name, remaining) return + case "comment": + handleCommentCommand(sub.Name, remaining) + return + case "worklog": + handleWorklogCommand(sub.Name, remaining) + return case "monitor": handleMonitorCommand(sub.Name, remaining) return @@ -735,6 +741,132 @@ func handleProposeCommand(subCmd string, args []string) { } } +func handleCommentCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "add": + taskCode, content := "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--content": + if i+1 < len(filtered) { + i++ + content = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunCommentAdd(taskCode, content, tokenFlag) + case "list": + taskCode := "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunCommentList(taskCode, tokenFlag) + default: + output.Errorf("hf comment %s is not implemented yet", subCmd) + } +} + +func handleWorklogCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "add": + taskCode, desc, date := "", "", "" + hours := 0.0 + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--hours": + if i+1 < len(filtered) { + i++ + if _, err := fmt.Sscanf(filtered[i], "%f", &hours); err != nil { + output.Error("--hours requires a numeric value") + } + } + case "--desc": + if i+1 < len(filtered) { + i++ + desc = filtered[i] + } + case "--date": + if i+1 < len(filtered) { + i++ + date = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunWorklogAdd(taskCode, hours, desc, date, tokenFlag) + case "list": + taskCode, username := "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--user": + if i+1 < len(filtered) { + i++ + username = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunWorklogList(taskCode, username, tokenFlag) + default: + output.Errorf("hf worklog %s is not implemented yet", subCmd) + } +} + func handleMonitorCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string diff --git a/internal/commands/comment.go b/internal/commands/comment.go new file mode 100644 index 0000000..ce51411 --- /dev/null +++ b/internal/commands/comment.go @@ -0,0 +1,136 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "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/output" +) + +type commentResponse struct { + ID int `json:"id"` + TaskID int `json:"task_id"` + AuthorID int `json:"author_id"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at"` +} + +func RunCommentAdd(taskCode, content, tokenFlag string) { + if taskCode == "" || content == "" { + output.Error("usage: hf comment add --task --content ") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + me := currentUser(c) + + payload := map[string]interface{}{ + "task_id": taskID, + "author_id": me.ID, + "content": content, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + data, err := c.Post("/comments", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add comment: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp commentResponse + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse response: %v", err) + } + fmt.Printf("comment added to %s: #%d\n", taskCode, resp.ID) +} + +func RunCommentList(taskCode, tokenFlag string) { + if taskCode == "" { + output.Error("usage: hf comment list --task ") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + data, err := c.Get(fmt.Sprintf("/tasks/%d/comments", taskID)) + if err != nil { + output.Errorf("failed to list comments: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var comments []commentResponse + if err := json.Unmarshal(data, &comments); err != nil { + output.Errorf("cannot parse comment list: %v", err) + } + + headers := []string{"ID", "AUTHOR", "CREATED", "CONTENT"} + var rows [][]string + for _, item := range comments { + content := item.Content + if len(content) > 60 { + content = content[:57] + "..." + } + rows = append(rows, []string{fmt.Sprintf("%d", item.ID), fmt.Sprintf("%d", item.AuthorID), item.CreatedAt, content}) + } + output.PrintTable(headers, rows) +} + +func newAuthedClient(tokenFlag string) *client.Client { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + return client.New(cfg.BaseURL, token) +} + +type authMeResponse struct { + ID int `json:"id"` + Username string `json:"username"` +} + +func currentUser(c *client.Client) authMeResponse { + data, err := c.Get("/auth/me") + if err != nil { + output.Errorf("failed to resolve current user: %v", err) + } + var me authMeResponse + if err := json.Unmarshal(data, &me); err != nil { + output.Errorf("cannot parse current user: %v", err) + } + return me +} + +func resolveTaskID(c *client.Client, taskCode string) int { + data, err := c.Get("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to resolve task %s: %v", taskCode, err) + } + var task taskResponse + if err := json.Unmarshal(data, &task); err != nil { + output.Errorf("cannot parse task %s: %v", taskCode, err) + } + return task.ID +} diff --git a/internal/commands/worklog.go b/internal/commands/worklog.go new file mode 100644 index 0000000..fa51328 --- /dev/null +++ b/internal/commands/worklog.go @@ -0,0 +1,124 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +type worklogResponse struct { + ID int `json:"id"` + TaskID int `json:"task_id"` + UserID int `json:"user_id"` + Hours float64 `json:"hours"` + Description *string `json:"description"` + LoggedDate string `json:"logged_date"` + CreatedAt string `json:"created_at"` +} + +func RunWorklogAdd(taskCode string, hours float64, desc, date, tokenFlag string) { + if taskCode == "" || hours <= 0 { + output.Error("usage: hf worklog add --task --hours [--desc ] [--date ]") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + me := currentUser(c) + + payload := map[string]interface{}{ + "task_id": taskID, + "user_id": me.ID, + "hours": hours, + } + if desc != "" { + payload["description"] = desc + } + if date != "" { + payload["logged_date"] = date + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + data, err := c.Post("/worklogs", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add worklog: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp worklogResponse + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse response: %v", err) + } + fmt.Printf("worklog added to %s: #%d (%.2fh)\n", taskCode, resp.ID, resp.Hours) +} + +func RunWorklogList(taskCode, username, tokenFlag string) { + if taskCode == "" && username == "" { + output.Error("usage: hf worklog list [--task ] [--user ]") + } + if taskCode != "" && username != "" { + output.Error("choose only one of --task or --user ") + } + + c := newAuthedClient(tokenFlag) + path := "" + if taskCode != "" { + taskID := resolveTaskID(c, taskCode) + path = fmt.Sprintf("/tasks/%d/worklogs", taskID) + } else { + path = fmt.Sprintf("/users/%s/worklogs", username) + } + + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list worklogs: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var logs []worklogResponse + if err := json.Unmarshal(data, &logs); err != nil { + output.Errorf("cannot parse worklog list: %v", err) + } + + headers := []string{"ID", "TASK", "USER", "HOURS", "DATE", "DESCRIPTION"} + var rows [][]string + for _, item := range logs { + desc := "" + if item.Description != nil { + desc = *item.Description + } + if len(desc) > 40 { + desc = desc[:37] + "..." + } + rows = append(rows, []string{ + fmt.Sprintf("%d", item.ID), + fmt.Sprintf("%d", item.TaskID), + fmt.Sprintf("%d", item.UserID), + fmt.Sprintf("%.2f", item.Hours), + item.LoggedDate, + desc, + }) + } + output.PrintTable(headers, rows) +} diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 6a26baf..1f157ce 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -165,6 +165,10 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept --milestone "}, Flags: authFlagHelp()}, "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject [--reason ]"}, Flags: authFlagHelp()}, "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen "}, Flags: authFlagHelp()}, + "comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task --content "}, Flags: authFlagHelp()}, + "comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task "}, Flags: authFlagHelp()}, + "worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task --hours [--desc ] [--date ]"}, Flags: authFlagHelp()}, + "worklog/list": {Summary: "List work logs by task or user", Usage: []string{"hf worklog list [--task ] [--user ]"}, Flags: authFlagHelp()}, "monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()}, "monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get ", "hf monitor server create --identifier [--name ]", "hf monitor server delete "}, Flags: authFlagHelp()}, "monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()}, diff --git a/internal/help/surface.go b/internal/help/surface.go index 89bf7ec..4085daf 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -138,6 +138,22 @@ func CommandSurface() []Group { {Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")}, }, }, + { + Name: "comment", + Description: "Manage task comments", + SubCommands: []Command{ + {Name: "add", Description: "Add a comment to a task", Permitted: has(perms, "task.read")}, + {Name: "list", Description: "List comments for a task", Permitted: has(perms, "task.read")}, + }, + }, + { + Name: "worklog", + Description: "Manage work logs", + SubCommands: []Command{ + {Name: "add", Description: "Add a work log entry", Permitted: has(perms, "task.read")}, + {Name: "list", Description: "List work logs by task or user", Permitted: has(perms, "task.read")}, + }, + }, { Name: "monitor", Description: "Monitor servers and API keys",