diff --git a/cmd/hf/main.go b/cmd/hf/main.go index faf2796..8737869 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -143,6 +143,21 @@ func handleGroup(group help.Group, args []string) { case "user": handleUserCommand(sub.Name, remaining) return + case "role": + handleRoleCommand(sub.Name, remaining) + return + case "permission": + handlePermissionCommand(sub.Name, remaining) + return + case "project": + handleProjectCommand(sub.Name, remaining) + return + case "milestone": + handleMilestoneCommand(sub.Name, remaining) + return + case "task": + handleTaskCommand(sub.Name, remaining) + return } output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) @@ -256,3 +271,272 @@ func findSubCommand(group help.Group, name string) (help.Command, bool) { } return help.Command{}, false } + +func handleRoleCommand(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.RunRoleList(tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf role get ") + } + commands.RunRoleGet(filtered[0], tokenFlag) + case "create": + name, desc := "", "" + global := false + var remaining []string + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--name": + if i+1 < len(filtered) { + i++ + name = filtered[i] + } + case "--desc": + if i+1 < len(filtered) { + i++ + desc = filtered[i] + } + case "--global": + if i+1 < len(filtered) { + i++ + global = filtered[i] == "true" + } + default: + remaining = append(remaining, filtered[i]) + } + } + _ = remaining + if name == "" { + output.Error("usage: hf role create --name ") + } + commands.RunRoleCreate(name, desc, global, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf role update [--desc ...]") + } + commands.RunRoleUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf role delete ") + } + commands.RunRoleDelete(filtered[0], tokenFlag) + case "set-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role set-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleSetPermissions(roleName, perms, tokenFlag) + case "add-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role add-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleAddPermissions(roleName, perms, tokenFlag) + case "remove-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role remove-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleRemovePermissions(roleName, perms, tokenFlag) + default: + output.Errorf("hf role %s is not implemented yet", subCmd) + } +} + +func extractPermissions(args []string) []string { + var perms []string + for i := 0; i < len(args); i++ { + if args[i] == "--permission" && i+1 < len(args) { + i++ + perms = append(perms, args[i]) + } + } + return perms +} + +func handlePermissionCommand(subCmd string, args []string) { + tokenFlag := "" + for i := 0; i < len(args); i++ { + if args[i] == "--token" && i+1 < len(args) { + i++ + tokenFlag = args[i] + } + } + + switch subCmd { + case "list": + commands.RunPermissionList(tokenFlag) + default: + output.Errorf("hf permission %s is not implemented yet", subCmd) + } +} + +func handleMilestoneCommand(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.RunMilestoneList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf milestone get ") + } + commands.RunMilestoneGet(filtered[0], tokenFlag) + case "create": + commands.RunMilestoneCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf milestone update [--title ...] [--desc ...] [--status ...] [--due ...]") + } + commands.RunMilestoneUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf milestone delete ") + } + commands.RunMilestoneDelete(filtered[0], tokenFlag) + case "progress": + if len(filtered) < 1 { + output.Error("usage: hf milestone progress ") + } + commands.RunMilestoneProgress(filtered[0], tokenFlag) + default: + output.Errorf("hf milestone %s is not implemented yet", subCmd) + } +} + +func handleTaskCommand(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.RunTaskList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf task get ") + } + commands.RunTaskGet(filtered[0], tokenFlag) + case "create": + commands.RunTaskCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf task update [--title ...] [--desc ...] [--status ...] [--priority ...] [--assignee ...]") + } + commands.RunTaskUpdate(filtered[0], filtered[1:], tokenFlag) + case "transition": + if len(filtered) < 2 { + output.Error("usage: hf task transition ") + } + commands.RunTaskTransition(filtered[0], filtered[1], tokenFlag) + case "take": + if len(filtered) < 1 { + output.Error("usage: hf task take ") + } + commands.RunTaskTake(filtered[0], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf task delete ") + } + commands.RunTaskDelete(filtered[0], tokenFlag) + case "search": + commands.RunTaskSearch(filtered, tokenFlag) + default: + output.Errorf("hf task %s is not implemented yet", subCmd) + } +} + +func handleProjectCommand(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.RunProjectList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf project get ") + } + commands.RunProjectGet(filtered[0], tokenFlag) + case "create": + commands.RunProjectCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf project update [--name ...] [--desc ...] [--repo ...]") + } + commands.RunProjectUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf project delete ") + } + commands.RunProjectDelete(filtered[0], tokenFlag) + case "members": + if len(filtered) < 1 { + output.Error("usage: hf project members ") + } + commands.RunProjectMembers(filtered[0], tokenFlag) + case "add-member": + if len(filtered) < 1 { + output.Error("usage: hf project add-member --user --role ") + } + commands.RunProjectAddMember(filtered[0], filtered[1:], tokenFlag) + case "remove-member": + if len(filtered) < 1 { + output.Error("usage: hf project remove-member --user ") + } + commands.RunProjectRemoveMember(filtered[0], filtered[1:], tokenFlag) + default: + output.Errorf("hf project %s is not implemented yet", subCmd) + } +} diff --git a/internal/commands/milestone.go b/internal/commands/milestone.go new file mode 100644 index 0000000..dea241c --- /dev/null +++ b/internal/commands/milestone.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" +) + +// milestoneResponse matches the backend MilestoneResponse schema. +type milestoneResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + DueDate *string `json:"due_date"` + ProjectCode string `json:"project_code"` + CreatedAt string `json:"created_at"` +} + +// milestoneProgressResponse matches the backend progress response. +type milestoneProgressResponse struct { + Code string `json:"code"` + Title string `json:"title"` + Status string `json:"status"` + TotalTasks int `json:"total_tasks"` + DoneTasks int `json:"done_tasks"` + Progress float64 `json:"progress"` +} + +// RunMilestoneList implements `hf milestone list --project `. +func RunMilestoneList(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 := "/milestones" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list milestones: %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 milestones []milestoneResponse + if err := json.Unmarshal(data, &milestones); err != nil { + output.Errorf("cannot parse milestone list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "DUE DATE", "PROJECT"} + var rows [][]string + for _, m := range milestones { + due := "" + if m.DueDate != nil { + due = *m.DueDate + } + rows = append(rows, []string{m.Code, m.Title, m.Status, due, m.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunMilestoneGet implements `hf milestone get `. +func RunMilestoneGet(milestoneCode, 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("/milestones/" + milestoneCode) + if err != nil { + output.Errorf("failed to get milestone: %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 milestoneResponse + if err := json.Unmarshal(data, &m); err != nil { + output.Errorf("cannot parse milestone: %v", err) + } + + desc := "" + if m.Description != nil { + desc = *m.Description + } + due := "" + if m.DueDate != nil { + due = *m.DueDate + } + output.PrintKeyValue( + "code", m.Code, + "title", m.Title, + "description", desc, + "status", m.Status, + "due-date", due, + "project", m.ProjectCode, + "created", m.CreatedAt, + ) +} + +// RunMilestoneCreate implements `hf milestone create`. +func RunMilestoneCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, desc, due := "", "", "", "" + 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] + case "--due": + if i+1 >= len(args) { + output.Error("--due requires a value") + } + i++ + due = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" { + output.Error("usage: hf milestone create --project --title ") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if desc != "" { + payload["description"] = desc + } + if due != "" { + payload["due_date"] = due + } + + 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("/milestones", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create milestone: %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 milestoneResponse + if err := json.Unmarshal(data, &m); err != nil { + fmt.Printf("milestone created: %s\n", title) + return + } + fmt.Printf("milestone created: %s (code: %s)\n", m.Title, m.Code) +} + +// RunMilestoneUpdate implements `hf milestone update <milestone-code>`. +func RunMilestoneUpdate(milestoneCode 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 "--due": + if i+1 >= len(args) { + output.Error("--due requires a value") + } + i++ + payload["due_date"] = 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("/milestones/"+milestoneCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update milestone: %v", err) + } + + fmt.Printf("milestone updated: %s\n", milestoneCode) +} + +// RunMilestoneDelete implements `hf milestone delete <milestone-code>`. +func RunMilestoneDelete(milestoneCode, 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("/milestones/" + milestoneCode) + if err != nil { + output.Errorf("failed to delete milestone: %v", err) + } + fmt.Printf("milestone deleted: %s\n", milestoneCode) +} + +// RunMilestoneProgress implements `hf milestone progress <milestone-code>`. +func RunMilestoneProgress(milestoneCode, 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("/milestones/" + milestoneCode + "/progress") + if err != nil { + output.Errorf("failed to get milestone progress: %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 milestoneProgressResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse progress: %v", err) + } + + output.PrintKeyValue( + "code", p.Code, + "title", p.Title, + "status", p.Status, + "total-tasks", fmt.Sprintf("%d", p.TotalTasks), + "done-tasks", fmt.Sprintf("%d", p.DoneTasks), + "progress", fmt.Sprintf("%.1f%%", p.Progress*100), + ) +} diff --git a/internal/commands/project.go b/internal/commands/project.go new file mode 100644 index 0000000..12ed9f0 --- /dev/null +++ b/internal/commands/project.go @@ -0,0 +1,413 @@ +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" +) + +// projectResponse matches the backend ProjectResponse schema. +type projectResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description *string `json:"description"` + Repo *string `json:"repo"` + Owner string `json:"owner"` + CreatedAt string `json:"created_at"` +} + +// projectMemberResponse matches the backend ProjectMemberResponse schema. +type projectMemberResponse struct { + Username string `json:"username"` + RoleName string `json:"role_name"` +} + +// RunProjectList implements `hf project list`. +func RunProjectList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + // Build query params + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--owner": + if i+1 >= len(args) { + output.Error("--owner requires a value") + } + i++ + query = appendQuery(query, "owner", 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 := "/projects" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list projects: %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 projects []projectResponse + if err := json.Unmarshal(data, &projects); err != nil { + output.Errorf("cannot parse project list: %v", err) + } + + headers := []string{"CODE", "NAME", "OWNER", "DESCRIPTION"} + var rows [][]string + for _, p := range projects { + desc := "" + if p.Description != nil { + desc = *p.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + } + rows = append(rows, []string{p.Code, p.Name, p.Owner, desc}) + } + output.PrintTable(headers, rows) +} + +// RunProjectGet implements `hf project get <project-code>`. +func RunProjectGet(projectCode, 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("/projects/" + projectCode) + if err != nil { + output.Errorf("failed to get project: %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 projectResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse project: %v", err) + } + + desc := "" + if p.Description != nil { + desc = *p.Description + } + repo := "" + if p.Repo != nil { + repo = *p.Repo + } + output.PrintKeyValue( + "code", p.Code, + "name", p.Name, + "description", desc, + "repo", repo, + "owner", p.Owner, + "created", p.CreatedAt, + ) +} + +// RunProjectCreate implements `hf project create`. +func RunProjectCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + name, desc, repo := "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + name = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--repo": + if i+1 >= len(args) { + output.Error("--repo requires a value") + } + i++ + repo = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if name == "" { + output.Error("usage: hf project create --name <name>") + } + + payload := map[string]interface{}{ + "name": name, + } + if desc != "" { + payload["description"] = desc + } + if repo != "" { + payload["repo"] = repo + } + + 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("/projects", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create project: %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 projectResponse + if err := json.Unmarshal(data, &p); err != nil { + fmt.Printf("project created: %s\n", name) + return + } + fmt.Printf("project created: %s (code: %s)\n", p.Name, p.Code) +} + +// RunProjectUpdate implements `hf project update <project-code>`. +func RunProjectUpdate(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + payload["name"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--repo": + if i+1 >= len(args) { + output.Error("--repo requires a value") + } + i++ + payload["repo"] = 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("/projects/"+projectCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update project: %v", err) + } + + fmt.Printf("project updated: %s\n", projectCode) +} + +// RunProjectDelete implements `hf project delete <project-code>`. +func RunProjectDelete(projectCode, 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("/projects/" + projectCode) + if err != nil { + output.Errorf("failed to delete project: %v", err) + } + fmt.Printf("project deleted: %s\n", projectCode) +} + +// RunProjectMembers implements `hf project members <project-code>`. +func RunProjectMembers(projectCode, 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("/projects/" + projectCode + "/members") + if err != nil { + output.Errorf("failed to list project members: %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 members []projectMemberResponse + if err := json.Unmarshal(data, &members); err != nil { + output.Errorf("cannot parse member list: %v", err) + } + + headers := []string{"USERNAME", "ROLE"} + var rows [][]string + for _, m := range members { + rows = append(rows, []string{m.Username, m.RoleName}) + } + output.PrintTable(headers, rows) +} + +// RunProjectAddMember implements `hf project add-member <project-code> --user <username> --role <role-name>`. +func RunProjectAddMember(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + username, roleName := "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--user": + if i+1 >= len(args) { + output.Error("--user requires a value") + } + i++ + username = args[i] + case "--role": + if i+1 >= len(args) { + output.Error("--role requires a value") + } + i++ + roleName = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if username == "" || roleName == "" { + output.Error("usage: hf project add-member <project-code> --user <username> --role <role-name>") + } + + payload := map[string]interface{}{ + "username": username, + "role_name": roleName, + } + + 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("/projects/"+projectCode+"/members", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add member: %v", err) + } + + fmt.Printf("member %s added to project %s with role %s\n", username, projectCode, roleName) +} + +// RunProjectRemoveMember implements `hf project remove-member <project-code> --user <username>`. +func RunProjectRemoveMember(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + username := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--user": + if i+1 >= len(args) { + output.Error("--user requires a value") + } + i++ + username = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if username == "" { + output.Error("usage: hf project remove-member <project-code> --user <username>") + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/projects/" + projectCode + "/members/" + username) + if err != nil { + output.Errorf("failed to remove member: %v", err) + } + + fmt.Printf("member %s removed from project %s\n", username, projectCode) +} + +// appendQuery is a helper for building query strings. +func appendQuery(existing, key, value string) string { + if existing == "" { + return key + "=" + value + } + return existing + "&" + key + "=" + value +} diff --git a/internal/commands/role.go b/internal/commands/role.go new file mode 100644 index 0000000..bfe4732 --- /dev/null +++ b/internal/commands/role.go @@ -0,0 +1,334 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "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" +) + +// roleResponse matches the backend RoleResponse schema. +type roleResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + Permissions []string `json:"permissions"` + CreatedAt string `json:"created_at"` +} + +// permissionResponse matches the backend PermissionResponse schema. +type permissionResponse struct { + ID int `json:"id"` + Codename string `json:"codename"` + Description string `json:"description"` +} + +// RunRoleList implements `hf role list`. +func RunRoleList(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("/roles") + if err != nil { + output.Errorf("failed to list roles: %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 roles []roleResponse + if err := json.Unmarshal(data, &roles); err != nil { + output.Errorf("cannot parse role list: %v", err) + } + + headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERMISSIONS"} + var rows [][]string + for _, r := range roles { + global := "" + if r.IsGlobal { + global = "yes" + } + perms := strings.Join(r.Permissions, ", ") + if len(perms) > 60 { + perms = perms[:57] + "..." + } + rows = append(rows, []string{r.Name, r.Description, global, perms}) + } + output.PrintTable(headers, rows) +} + +// RunRoleGet implements `hf role get <role-name>`. +func RunRoleGet(roleName, 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("/roles/" + roleName) + if err != nil { + output.Errorf("failed to get role: %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 r roleResponse + if err := json.Unmarshal(data, &r); err != nil { + output.Errorf("cannot parse role: %v", err) + } + + global := "no" + if r.IsGlobal { + global = "yes" + } + perms := "(none)" + if len(r.Permissions) > 0 { + perms = strings.Join(r.Permissions, ", ") + } + output.PrintKeyValue( + "name", r.Name, + "description", r.Description, + "global", global, + "permissions", perms, + "created", r.CreatedAt, + ) +} + +// RunRoleCreate implements `hf role create`. +func RunRoleCreate(name, desc string, global bool, tokenFlag string) { + token := ResolveToken(tokenFlag) + if name == "" { + output.Error("usage: hf role create --name <role-name>") + } + + payload := map[string]interface{}{ + "name": name, + } + if desc != "" { + payload["description"] = desc + } + if global { + payload["is_global"] = true + } + + 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("/roles", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create role: %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("role created: %s\n", name) +} + +// RunRoleUpdate implements `hf role update <role-name>`. +func RunRoleUpdate(roleName string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch 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("/roles/"+roleName, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update role: %v", err) + } + + fmt.Printf("role updated: %s\n", roleName) +} + +// RunRoleDelete implements `hf role delete <role-name>`. +func RunRoleDelete(roleName, 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("/roles/" + roleName) + if err != nil { + output.Errorf("failed to delete role: %v", err) + } + fmt.Printf("role deleted: %s\n", roleName) +} + +// RunRoleSetPermissions implements `hf role set-permissions <role-name> --permission <perm> [...]`. +func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + 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.Put("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to set permissions: %v", err) + } + + fmt.Printf("permissions set for role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunRoleAddPermissions implements `hf role add-permissions <role-name> --permission <perm> [...]`. +func RunRoleAddPermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + 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("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add permissions: %v", err) + } + + fmt.Printf("permissions added to role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunRoleRemovePermissions implements `hf role remove-permissions <role-name> --permission <perm> [...]`. +func RunRoleRemovePermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + 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.Do("DELETE", "/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to remove permissions: %v", err) + } + + fmt.Printf("permissions removed from role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunPermissionList implements `hf permission list`. +func RunPermissionList(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("/permissions") + if err != nil { + output.Errorf("failed to list permissions: %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 perms []permissionResponse + if err := json.Unmarshal(data, &perms); err != nil { + output.Errorf("cannot parse permission list: %v", err) + } + + headers := []string{"CODENAME", "DESCRIPTION"} + var rows [][]string + for _, p := range perms { + rows = append(rows, []string{p.Codename, p.Description}) + } + output.PrintTable(headers, rows) +} diff --git a/internal/commands/task.go b/internal/commands/task.go new file mode 100644 index 0000000..b006221 --- /dev/null +++ b/internal/commands/task.go @@ -0,0 +1,478 @@ +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" +) + +// taskResponse matches the backend TaskResponse schema. +type taskResponse 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"` + Type string `json:"type"` + DueDate *string `json:"due_date"` + ProjectCode string `json:"project_code"` + MilestoneCode *string `json:"milestone_code"` + TakenBy *string `json:"taken_by"` + CreatedAt string `json:"created_at"` +} + +// RunTaskList implements `hf task list`. +func RunTaskList(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 "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + query = appendQuery(query, "milestone", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", 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 "--due-today": + if i+1 >= len(args) { + output.Error("--due-today requires a value") + } + i++ + query = appendQuery(query, "due_today", 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 := "/tasks" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list tasks: %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 tasks []taskResponse + if err := json.Unmarshal(data, &tasks); err != nil { + output.Errorf("cannot parse task list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"} + var rows [][]string + for _, t := range tasks { + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + title := t.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunTaskGet implements `hf task get <task-code>`. +func RunTaskGet(taskCode, 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("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to get task: %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 t taskResponse + if err := json.Unmarshal(data, &t); err != nil { + output.Errorf("cannot parse task: %v", err) + } + + desc := "" + if t.Description != nil { + desc = *t.Description + } + due := "" + if t.DueDate != nil { + due = *t.DueDate + } + milestone := "" + if t.MilestoneCode != nil { + milestone = *t.MilestoneCode + } + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + output.PrintKeyValue( + "code", t.Code, + "title", t.Title, + "description", desc, + "status", t.Status, + "priority", t.Priority, + "type", t.Type, + "due-date", due, + "project", t.ProjectCode, + "milestone", milestone, + "taken-by", takenBy, + "created", t.CreatedAt, + ) +} + +// RunTaskCreate implements `hf task create`. +func RunTaskCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, milestone, taskType, priority, 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 "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + case "--type": + if i+1 >= len(args) { + output.Error("--type requires a value") + } + i++ + taskType = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + priority = 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 == "" { + output.Error("usage: hf task create --project <project-code> --title <title>") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if milestone != "" { + payload["milestone_code"] = milestone + } + if taskType != "" { + payload["type"] = taskType + } + if priority != "" { + payload["priority"] = priority + } + if desc != "" { + payload["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("/tasks", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create task: %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 t taskResponse + if err := json.Unmarshal(data, &t); err != nil { + fmt.Printf("task created: %s\n", title) + return + } + fmt.Printf("task created: %s (code: %s)\n", t.Title, t.Code) +} + +// RunTaskUpdate implements `hf task update <task-code>`. +func RunTaskUpdate(taskCode 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] + case "--assignee": + if i+1 >= len(args) { + output.Error("--assignee requires a value") + } + i++ + val := args[i] + if val == "null" { + payload["taken_by"] = nil + } else { + payload["taken_by"] = val + } + 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("/tasks/"+taskCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update task: %v", err) + } + + fmt.Printf("task updated: %s\n", taskCode) +} + +// RunTaskTransition implements `hf task transition <task-code> <status>`. +func RunTaskTransition(taskCode, 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("/tasks/"+taskCode+"/transition", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to transition task: %v", err) + } + + fmt.Printf("task %s transitioned to %s\n", taskCode, status) +} + +// RunTaskTake implements `hf task take <task-code>`. +func RunTaskTake(taskCode, 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("/tasks/"+taskCode+"/take", nil) + if err != nil { + output.Errorf("failed to take task: %v", err) + } + + fmt.Printf("task taken: %s\n", taskCode) +} + +// RunTaskDelete implements `hf task delete <task-code>`. +func RunTaskDelete(taskCode, 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("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to delete task: %v", err) + } + fmt.Printf("task deleted: %s\n", taskCode) +} + +// RunTaskSearch implements `hf task search`. +func RunTaskSearch(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--query": + if i+1 >= len(args) { + output.Error("--query requires a value") + } + i++ + query = appendQuery(query, "q", 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]) + 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 := "/tasks/search" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to search tasks: %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 tasks []taskResponse + if err := json.Unmarshal(data, &tasks); err != nil { + output.Errorf("cannot parse task list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"} + var rows [][]string + for _, t := range tasks { + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + title := t.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode}) + } + output.PrintTable(headers, rows) +}