Add comment and worklog CLI commands
This commit is contained in:
@@ -90,11 +90,12 @@ internal/
|
|||||||
- `hf meeting` — list, get, create, update, attend, delete
|
- `hf meeting` — list, get, create, update, attend, delete
|
||||||
- `hf support` — list, get, create, update, take, transition, delete
|
- `hf support` — list, get, create, update, take, transition, delete
|
||||||
- `hf propose` — list, get, create, update, accept, reject, reopen
|
- `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)
|
- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke)
|
||||||
|
|
||||||
### Pending
|
### Pending
|
||||||
|
|
||||||
- Backend code-based endpoint support (some commands still use id-based API routes)
|
- Backend code-based endpoint support (some commands still use id-based API routes)
|
||||||
- Comment and worklog commands
|
|
||||||
- Cross-platform binary packaging / release pipeline
|
- Cross-platform binary packaging / release pipeline
|
||||||
- Integration tests
|
- Integration tests
|
||||||
|
|||||||
132
cmd/hf/main.go
132
cmd/hf/main.go
@@ -190,6 +190,12 @@ func handleGroup(group help.Group, args []string) {
|
|||||||
case "propose":
|
case "propose":
|
||||||
handleProposeCommand(sub.Name, remaining)
|
handleProposeCommand(sub.Name, remaining)
|
||||||
return
|
return
|
||||||
|
case "comment":
|
||||||
|
handleCommentCommand(sub.Name, remaining)
|
||||||
|
return
|
||||||
|
case "worklog":
|
||||||
|
handleWorklogCommand(sub.Name, remaining)
|
||||||
|
return
|
||||||
case "monitor":
|
case "monitor":
|
||||||
handleMonitorCommand(sub.Name, remaining)
|
handleMonitorCommand(sub.Name, remaining)
|
||||||
return
|
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) {
|
func handleMonitorCommand(subCmd string, args []string) {
|
||||||
tokenFlag := ""
|
tokenFlag := ""
|
||||||
var filtered []string
|
var filtered []string
|
||||||
|
|||||||
136
internal/commands/comment.go
Normal file
136
internal/commands/comment.go
Normal file
@@ -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 <task-code> --content <text>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <task-code>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
124
internal/commands/worklog.go
Normal file
124
internal/commands/worklog.go
Normal file
@@ -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 <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <task-code>] [--user <username>]")
|
||||||
|
}
|
||||||
|
if taskCode != "" && username != "" {
|
||||||
|
output.Error("choose only one of --task <task-code> or --user <username>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -165,6 +165,10 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
|
|||||||
"propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()},
|
"propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()},
|
||||||
"propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()},
|
"propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()},
|
||||||
"propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()},
|
"propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()},
|
||||||
|
"comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()},
|
||||||
|
"comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()},
|
||||||
|
"worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
|
||||||
|
"worklog/list": {Summary: "List work logs by task or user", Usage: []string{"hf worklog list [--task <task-code>] [--user <username>]"}, Flags: authFlagHelp()},
|
||||||
"monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, 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 <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
|
"monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
|
||||||
"monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()},
|
"monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()},
|
||||||
|
|||||||
@@ -138,6 +138,22 @@ func CommandSurface() []Group {
|
|||||||
{Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")},
|
{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",
|
Name: "monitor",
|
||||||
Description: "Monitor servers and API keys",
|
Description: "Monitor servers and API keys",
|
||||||
|
|||||||
Reference in New Issue
Block a user