From 34f52cb9e398ad215ec93acc3fb8dbb475c952da Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 15:24:43 +0000 Subject: [PATCH] feat: implement meeting, support, propose, and monitor command groups - Added meeting.go: list, get, create, update, attend, delete - Added support.go: list, get, create, update, take, transition, delete - Added propose.go: list, get, create, update, accept, reject, reopen - Added monitor.go: overview, server list/get/create/delete, api-key generate/revoke - Updated main.go with dispatch handlers for all four new groups - All commands follow existing patterns (token resolution, --json, table output) Covers TODO items 1.12, 1.13, 1.14, 1.15 from hf-cross-project-todo.md --- cmd/hf/main.go | 236 ++++++++++++++++++++++ internal/commands/meeting.go | 342 ++++++++++++++++++++++++++++++++ internal/commands/monitor.go | 279 ++++++++++++++++++++++++++ internal/commands/propose.go | 365 +++++++++++++++++++++++++++++++++++ internal/commands/support.go | 349 +++++++++++++++++++++++++++++++++ 5 files changed, 1571 insertions(+) create mode 100644 internal/commands/meeting.go create mode 100644 internal/commands/monitor.go create mode 100644 internal/commands/propose.go create mode 100644 internal/commands/support.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 8737869..46c8222 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -158,6 +158,18 @@ func handleGroup(group help.Group, args []string) { case "task": handleTaskCommand(sub.Name, remaining) return + case "meeting": + handleMeetingCommand(sub.Name, remaining) + return + case "support": + handleSupportCommand(sub.Name, remaining) + return + case "propose": + handleProposeCommand(sub.Name, remaining) + return + case "monitor": + handleMonitorCommand(sub.Name, remaining) + return } output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) @@ -540,3 +552,227 @@ func handleProjectCommand(subCmd string, args []string) { output.Errorf("hf project %s is not implemented yet", subCmd) } } + +func handleMeetingCommand(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 "list": + commands.RunMeetingList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf meeting get ") + } + commands.RunMeetingGet(filtered[0], tokenFlag) + case "create": + commands.RunMeetingCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf meeting update [--title ...] [--desc ...] [--status ...] [--time ...]") + } + commands.RunMeetingUpdate(filtered[0], filtered[1:], tokenFlag) + case "attend": + if len(filtered) < 1 { + output.Error("usage: hf meeting attend ") + } + commands.RunMeetingAttend(filtered[0], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf meeting delete ") + } + commands.RunMeetingDelete(filtered[0], tokenFlag) + default: + output.Errorf("hf meeting %s is not implemented yet", subCmd) + } +} + +func handleSupportCommand(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 "list": + commands.RunSupportList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf support get ") + } + commands.RunSupportGet(filtered[0], tokenFlag) + case "create": + commands.RunSupportCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf support update [--title ...] [--desc ...] [--status ...] [--priority ...]") + } + commands.RunSupportUpdate(filtered[0], filtered[1:], tokenFlag) + case "take": + if len(filtered) < 1 { + output.Error("usage: hf support take ") + } + commands.RunSupportTake(filtered[0], tokenFlag) + case "transition": + if len(filtered) < 2 { + output.Error("usage: hf support transition ") + } + commands.RunSupportTransition(filtered[0], filtered[1], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf support delete ") + } + commands.RunSupportDelete(filtered[0], tokenFlag) + default: + output.Errorf("hf support %s is not implemented yet", subCmd) + } +} + +func handleProposeCommand(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 "list": + commands.RunProposeList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf propose get ") + } + commands.RunProposeGet(filtered[0], tokenFlag) + case "create": + commands.RunProposeCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf propose update [--title ...] [--desc ...]") + } + commands.RunProposeUpdate(filtered[0], filtered[1:], tokenFlag) + case "accept": + if len(filtered) < 1 { + output.Error("usage: hf propose accept --milestone ") + } + commands.RunProposeAccept(filtered[0], filtered[1:], tokenFlag) + case "reject": + if len(filtered) < 1 { + output.Error("usage: hf propose reject [--reason ]") + } + commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag) + case "reopen": + if len(filtered) < 1 { + output.Error("usage: hf propose reopen ") + } + commands.RunProposeReopen(filtered[0], tokenFlag) + default: + output.Errorf("hf propose %s is not implemented yet", subCmd) + } +} + +func handleMonitorCommand(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 "overview": + commands.RunMonitorOverview(tokenFlag) + case "server": + handleMonitorServerCommand(filtered, tokenFlag) + case "api-key": + handleMonitorAPIKeyCommand(filtered, tokenFlag) + default: + output.Errorf("hf monitor %s is not implemented yet", subCmd) + } +} + +func handleMonitorServerCommand(args []string, tokenFlag string) { + if len(args) == 0 { + output.Error("usage: hf monitor server ...") + } + + subCmd := args[0] + remaining := args[1:] + + switch subCmd { + case "list": + commands.RunMonitorServerList(tokenFlag) + case "get": + if len(remaining) < 1 { + output.Error("usage: hf monitor server get ") + } + commands.RunMonitorServerGet(remaining[0], tokenFlag) + case "create": + commands.RunMonitorServerCreate(remaining, tokenFlag) + case "delete": + if len(remaining) < 1 { + output.Error("usage: hf monitor server delete ") + } + commands.RunMonitorServerDelete(remaining[0], tokenFlag) + default: + output.Errorf("unknown monitor server subcommand: %s", subCmd) + } +} + +func handleMonitorAPIKeyCommand(args []string, tokenFlag string) { + if len(args) == 0 { + output.Error("usage: hf monitor api-key ") + } + + subCmd := args[0] + remaining := args[1:] + + switch subCmd { + case "generate": + if len(remaining) < 1 { + output.Error("usage: hf monitor api-key generate ") + } + commands.RunMonitorAPIKeyGenerate(remaining[0], tokenFlag) + case "revoke": + if len(remaining) < 1 { + output.Error("usage: hf monitor api-key revoke ") + } + commands.RunMonitorAPIKeyRevoke(remaining[0], tokenFlag) + default: + output.Errorf("unknown monitor api-key subcommand: %s", subCmd) + } +} diff --git a/internal/commands/meeting.go b/internal/commands/meeting.go new file mode 100644 index 0000000..0d34285 --- /dev/null +++ b/internal/commands/meeting.go @@ -0,0 +1,342 @@ +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" +) + +// meetingResponse matches the backend MeetingResponse schema. +type meetingResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + MeetingTime *string `json:"meeting_time"` + ProjectCode string `json:"project_code"` + MilestoneCode *string `json:"milestone_code"` + Participants []string `json:"participants"` + CreatedAt string `json:"created_at"` +} + +// RunMeetingList implements `hf meeting list`. +func RunMeetingList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/meetings" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list meetings: %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 meetings []meetingResponse + if err := json.Unmarshal(data, &meetings); err != nil { + output.Errorf("cannot parse meeting list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "TIME", "PROJECT"} + var rows [][]string + for _, m := range meetings { + meetTime := "" + if m.MeetingTime != nil { + meetTime = *m.MeetingTime + } + title := m.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{m.Code, title, m.Status, meetTime, m.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunMeetingGet implements `hf meeting get `. +func RunMeetingGet(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/meetings/" + meetingCode) + if err != nil { + output.Errorf("failed to get meeting: %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 m meetingResponse + if err := json.Unmarshal(data, &m); err != nil { + output.Errorf("cannot parse meeting: %v", err) + } + + desc := "" + if m.Description != nil { + desc = *m.Description + } + meetTime := "" + if m.MeetingTime != nil { + meetTime = *m.MeetingTime + } + milestone := "" + if m.MilestoneCode != nil { + milestone = *m.MilestoneCode + } + participants := "" + if len(m.Participants) > 0 { + for i, p := range m.Participants { + if i > 0 { + participants += ", " + } + participants += p + } + } + output.PrintKeyValue( + "code", m.Code, + "title", m.Title, + "description", desc, + "status", m.Status, + "time", meetTime, + "project", m.ProjectCode, + "milestone", milestone, + "participants", participants, + "created", m.CreatedAt, + ) +} + +// RunMeetingCreate implements `hf meeting create`. +func RunMeetingCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, milestone, desc, meetTime := "", "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--time": + if i+1 >= len(args) { + output.Error("--time requires a value") + } + i++ + meetTime = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" { + output.Error("usage: hf meeting create --project --title ") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if milestone != "" { + payload["milestone_code"] = milestone + } + if desc != "" { + payload["description"] = desc + } + if meetTime != "" { + payload["meeting_time"] = meetTime + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/meetings", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create meeting: %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 m meetingResponse + if err := json.Unmarshal(data, &m); err != nil { + fmt.Printf("meeting created: %s\n", title) + return + } + fmt.Printf("meeting created: %s (code: %s)\n", m.Title, m.Code) +} + +// RunMeetingUpdate implements `hf meeting update <meeting-code>`. +func RunMeetingUpdate(meetingCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--time": + if i+1 >= len(args) { + output.Error("--time requires a value") + } + i++ + payload["meeting_time"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/meetings/"+meetingCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update meeting: %v", err) + } + + fmt.Printf("meeting updated: %s\n", meetingCode) +} + +// RunMeetingAttend implements `hf meeting attend <meeting-code>`. +func RunMeetingAttend(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/meetings/"+meetingCode+"/attend", nil) + if err != nil { + output.Errorf("failed to attend meeting: %v", err) + } + + fmt.Printf("attending meeting: %s\n", meetingCode) +} + +// RunMeetingDelete implements `hf meeting delete <meeting-code>`. +func RunMeetingDelete(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/meetings/" + meetingCode) + if err != nil { + output.Errorf("failed to delete meeting: %v", err) + } + fmt.Printf("meeting deleted: %s\n", meetingCode) +} diff --git a/internal/commands/monitor.go b/internal/commands/monitor.go new file mode 100644 index 0000000..ffe1a40 --- /dev/null +++ b/internal/commands/monitor.go @@ -0,0 +1,279 @@ +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" +) + +// monitorOverviewResponse matches the backend monitor overview schema. +type monitorOverviewResponse struct { + TotalServers int `json:"total_servers"` + OnlineServers int `json:"online_servers"` +} + +// monitorServerResponse matches the backend monitor server schema. +type monitorServerResponse struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + DisplayName *string `json:"display_name"` + Status string `json:"status"` + LastSeen *string `json:"last_seen"` + CreatedAt string `json:"created_at"` +} + +// monitorAPIKeyResponse matches the backend monitor API key schema. +type monitorAPIKeyResponse struct { + Identifier string `json:"identifier"` + APIKey string `json:"api_key"` +} + +// RunMonitorOverview implements `hf monitor overview`. +func RunMonitorOverview(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/overview") + if err != nil { + output.Errorf("failed to get monitor overview: %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 o monitorOverviewResponse + if err := json.Unmarshal(data, &o); err != nil { + output.Errorf("cannot parse monitor overview: %v", err) + } + + output.PrintKeyValue( + "total-servers", fmt.Sprintf("%d", o.TotalServers), + "online-servers", fmt.Sprintf("%d", o.OnlineServers), + ) +} + +// RunMonitorServerList implements `hf monitor server list`. +func RunMonitorServerList(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/servers") + if err != nil { + output.Errorf("failed to list monitor servers: %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 servers []monitorServerResponse + if err := json.Unmarshal(data, &servers); err != nil { + output.Errorf("cannot parse server list: %v", err) + } + + headers := []string{"IDENTIFIER", "NAME", "STATUS", "LAST SEEN"} + var rows [][]string + for _, s := range servers { + name := "" + if s.DisplayName != nil { + name = *s.DisplayName + } + lastSeen := "" + if s.LastSeen != nil { + lastSeen = *s.LastSeen + } + rows = append(rows, []string{s.Identifier, name, s.Status, lastSeen}) + } + output.PrintTable(headers, rows) +} + +// RunMonitorServerGet implements `hf monitor server get <identifier>`. +func RunMonitorServerGet(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/servers/" + identifier) + if err != nil { + output.Errorf("failed to get server: %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 s monitorServerResponse + if err := json.Unmarshal(data, &s); err != nil { + output.Errorf("cannot parse server: %v", err) + } + + name := "" + if s.DisplayName != nil { + name = *s.DisplayName + } + lastSeen := "" + if s.LastSeen != nil { + lastSeen = *s.LastSeen + } + output.PrintKeyValue( + "identifier", s.Identifier, + "name", name, + "status", s.Status, + "last-seen", lastSeen, + "created", s.CreatedAt, + ) +} + +// RunMonitorServerCreate implements `hf monitor server create --identifier <identifier>`. +func RunMonitorServerCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + identifier, name := "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--identifier": + if i+1 >= len(args) { + output.Error("--identifier requires a value") + } + i++ + identifier = args[i] + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + name = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if identifier == "" { + output.Error("usage: hf monitor server create --identifier <identifier>") + } + + payload := map[string]interface{}{ + "identifier": identifier, + } + if name != "" { + payload["display_name"] = name + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/monitor/servers", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create server: %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 + } + + fmt.Printf("monitor server created: %s\n", identifier) + _ = data +} + +// RunMonitorServerDelete implements `hf monitor server delete <identifier>`. +func RunMonitorServerDelete(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/monitor/servers/" + identifier) + if err != nil { + output.Errorf("failed to delete server: %v", err) + } + fmt.Printf("monitor server deleted: %s\n", identifier) +} + +// RunMonitorAPIKeyGenerate implements `hf monitor api-key generate <identifier>`. +func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil) + if err != nil { + output.Errorf("failed to generate API key: %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 k monitorAPIKeyResponse + if err := json.Unmarshal(data, &k); err != nil { + fmt.Printf("API key generated for: %s\n", identifier) + return + } + output.PrintKeyValue( + "identifier", k.Identifier, + "api-key", k.APIKey, + ) +} + +// RunMonitorAPIKeyRevoke implements `hf monitor api-key revoke <identifier>`. +func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/monitor/servers/" + identifier + "/api-key") + if err != nil { + output.Errorf("failed to revoke API key: %v", err) + } + fmt.Printf("API key revoked for: %s\n", identifier) +} diff --git a/internal/commands/propose.go b/internal/commands/propose.go new file mode 100644 index 0000000..1e7d95c --- /dev/null +++ b/internal/commands/propose.go @@ -0,0 +1,365 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "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" +) + +// proposeResponse matches the backend ProposeResponse schema. +type proposeResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + ProjectCode string `json:"project_code"` + CreatedBy *string `json:"created_by"` + CreatedAt string `json:"created_at"` +} + +// RunProposeList implements `hf propose list --project <project-code>`. +func RunProposeList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/proposes" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list proposals: %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 proposes []proposeResponse + if err := json.Unmarshal(data, &proposes); err != nil { + output.Errorf("cannot parse proposal list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PROJECT", "CREATED BY"} + var rows [][]string + for _, p := range proposes { + createdBy := "" + if p.CreatedBy != nil { + createdBy = *p.CreatedBy + } + title := p.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{p.Code, title, p.Status, p.ProjectCode, createdBy}) + } + output.PrintTable(headers, rows) +} + +// RunProposeGet implements `hf propose get <propose-code>`. +func RunProposeGet(proposeCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/proposes/" + proposeCode) + if err != nil { + output.Errorf("failed to get proposal: %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 p proposeResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse proposal: %v", err) + } + + desc := "" + if p.Description != nil { + desc = *p.Description + } + createdBy := "" + if p.CreatedBy != nil { + createdBy = *p.CreatedBy + } + output.PrintKeyValue( + "code", p.Code, + "title", p.Title, + "description", desc, + "status", p.Status, + "project", p.ProjectCode, + "created-by", createdBy, + "created", p.CreatedAt, + ) +} + +// RunProposeCreate implements `hf propose create`. +func RunProposeCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, desc := "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" || desc == "" { + output.Error("usage: hf propose create --project <project-code> --title <title> --desc <desc>") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + "description": desc, + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/proposes", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create proposal: %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 p proposeResponse + if err := json.Unmarshal(data, &p); err != nil { + fmt.Printf("proposal created: %s\n", title) + return + } + fmt.Printf("proposal created: %s (code: %s)\n", p.Title, p.Code) +} + +// RunProposeUpdate implements `hf propose update <propose-code>`. +func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update proposal: %v", err) + } + + fmt.Printf("proposal updated: %s\n", proposeCode) +} + +// RunProposeAccept implements `hf propose accept <propose-code> --milestone <milestone-code>`. +func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + milestone := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if milestone == "" { + output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>") + } + + payload := map[string]interface{}{ + "milestone_code": milestone, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to accept proposal: %v", err) + } + + fmt.Printf("proposal accepted: %s\n", proposeCode) +} + +// RunProposeReject implements `hf propose reject <propose-code>`. +func RunProposeReject(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + reason := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--reason": + if i+1 >= len(args) { + output.Error("--reason requires a value") + } + i++ + reason = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + var body io.Reader + if reason != "" { + payload := map[string]interface{}{ + "reason": reason, + } + data, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + body = bytes.NewReader(data) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/reject", body) + if err != nil { + output.Errorf("failed to reject proposal: %v", err) + } + + fmt.Printf("proposal rejected: %s\n", proposeCode) +} + +// RunProposeReopen implements `hf propose reopen <propose-code>`. +func RunProposeReopen(proposeCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/reopen", nil) + if err != nil { + output.Errorf("failed to reopen proposal: %v", err) + } + + fmt.Printf("proposal reopened: %s\n", proposeCode) +} diff --git a/internal/commands/support.go b/internal/commands/support.go new file mode 100644 index 0000000..80fc18d --- /dev/null +++ b/internal/commands/support.go @@ -0,0 +1,349 @@ +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" +) + +// supportResponse matches the backend SupportResponse schema. +type supportResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + ProjectCode *string `json:"project_code"` + TakenBy *string `json:"taken_by"` + CreatedAt string `json:"created_at"` +} + +// RunSupportList implements `hf support list`. +func RunSupportList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--taken-by": + if i+1 >= len(args) { + output.Error("--taken-by requires a value") + } + i++ + query = appendQuery(query, "taken_by", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/supports" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list support tickets: %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 tickets []supportResponse + if err := json.Unmarshal(data, &tickets); err != nil { + output.Errorf("cannot parse support list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY"} + var rows [][]string + for _, s := range tickets { + takenBy := "" + if s.TakenBy != nil { + takenBy = *s.TakenBy + } + title := s.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{s.Code, title, s.Status, s.Priority, takenBy}) + } + output.PrintTable(headers, rows) +} + +// RunSupportGet implements `hf support get <support-code>`. +func RunSupportGet(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/supports/" + supportCode) + if err != nil { + output.Errorf("failed to get support ticket: %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 s supportResponse + if err := json.Unmarshal(data, &s); err != nil { + output.Errorf("cannot parse support ticket: %v", err) + } + + desc := "" + if s.Description != nil { + desc = *s.Description + } + project := "" + if s.ProjectCode != nil { + project = *s.ProjectCode + } + takenBy := "" + if s.TakenBy != nil { + takenBy = *s.TakenBy + } + output.PrintKeyValue( + "code", s.Code, + "title", s.Title, + "description", desc, + "status", s.Status, + "priority", s.Priority, + "project", project, + "taken-by", takenBy, + "created", s.CreatedAt, + ) +} + +// RunSupportCreate implements `hf support create`. +func RunSupportCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + title, project, desc, priority := "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + priority = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if title == "" { + output.Error("usage: hf support create --title <title>") + } + + payload := map[string]interface{}{ + "title": title, + } + if project != "" { + payload["project_code"] = project + } + if desc != "" { + payload["description"] = desc + } + if priority != "" { + payload["priority"] = priority + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/supports", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create support ticket: %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 s supportResponse + if err := json.Unmarshal(data, &s); err != nil { + fmt.Printf("support ticket created: %s\n", title) + return + } + fmt.Printf("support ticket created: %s (code: %s)\n", s.Title, s.Code) +} + +// RunSupportUpdate implements `hf support update <support-code>`. +func RunSupportUpdate(supportCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + payload["priority"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/supports/"+supportCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update support ticket: %v", err) + } + + fmt.Printf("support ticket updated: %s\n", supportCode) +} + +// RunSupportTake implements `hf support take <support-code>`. +func RunSupportTake(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/supports/"+supportCode+"/take", nil) + if err != nil { + output.Errorf("failed to take support ticket: %v", err) + } + + fmt.Printf("support ticket taken: %s\n", supportCode) +} + +// RunSupportTransition implements `hf support transition <support-code> <status>`. +func RunSupportTransition(supportCode, status, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := map[string]interface{}{ + "status": status, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/supports/"+supportCode+"/transition", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to transition support ticket: %v", err) + } + + fmt.Printf("support ticket %s transitioned to %s\n", supportCode, status) +} + +// RunSupportDelete implements `hf support delete <support-code>`. +func RunSupportDelete(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/supports/" + supportCode) + if err != nil { + output.Errorf("failed to delete support ticket: %v", err) + } + fmt.Printf("support ticket deleted: %s\n", supportCode) +}