diff --git a/shortcuts/board/board.go b/shortcuts/board/board.go deleted file mode 100644 index 3def6cf..0000000 --- a/shortcuts/board/board.go +++ /dev/null @@ -1,294 +0,0 @@ -package board - -import ( - "fmt" - "net/url" - "sort" - "strconv" - "strings" - - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -// v1RepoPath returns the v1 API path prefix. -func v1RepoPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) -} - -// Shortcuts returns project board management shortcuts. -func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ - { - Name: "view", - Description: "Show project board (issues grouped by status)", - Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, - {Name: "milestone", Short: "m", Usage: "Milestone ID to filter by"}, - }, - Run: runView, - }, - { - Name: "columns", - Description: "List available board columns (issue statuses)", - Run: runColumns, - }, - { - Name: "move", - Description: "Move an issue to a different status column", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number", Required: true}, - {Name: "state", Short: "s", Usage: "Target state: open, in_progress, resolved, closed, or numeric status_id", Required: true}, - }, - Run: runMove, - }, - } -} - -// Default known status mapping (fallback if API call fails) -var knownStatuses = map[int]string{ - 1: "New", - 2: "In Progress", - 3: "Resolved", - 4: "Reopened", - 5: "Closed", - 6: "Rejected", -} - -// statusNameToID maps common names to status IDs -// Supports both Chinese and English names -var statusNameToID = map[string]int{ - "open": 1, - "new": 1, - "新增": 1, - "in_progress": 2, - "inprogress": 2, - "wip": 2, - "正在解决": 2, - "resolved": 3, - "已解决": 3, - "reopened": 4, - "closed": 5, - "close": 5, - "关闭": 5, - "rejected": 6, - "拒绝": 6, - "feedback": 6, -} - -// --- Commands --- - -func runView(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - q := url.Values{} - q.Set("limit", "100") - if s := ctx.Arg("state"); s != "" { - q.Set("state", s) - } - if m := ctx.Arg("milestone"); m != "" { - q.Set("fixed_version_id", m) - } - - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) - if err != nil { - return err - } - - // Parse issues and group by status - raw, ok := env.Data.(map[string]interface{}) - if !ok { - return ctx.Output(env) - } - - issuesRaw, ok := raw["issues"].([]interface{}) - if !ok { - // No issues, return empty board - return ctx.OutputData(map[string]interface{}{ - "columns": []map[string]interface{}{}, - "total": 0, - }) - } - - // Group issues by status_id - columns := make(map[int][]map[string]interface{}) - statusOrder := make([]int, 0) - - for _, rawIssue := range issuesRaw { - issue, ok := rawIssue.(map[string]interface{}) - if !ok { - continue - } - - // Extract number - number := 0 - if n, ok := issue["project_issues_index"].(float64); ok { - number = int(n) - } else if n, ok := issue["number"]; ok { - if v, ok := n.(float64); ok { - number = int(v) - } - } - - // Extract status_id - statusID := 0 - if s, ok := issue["status_id"]; ok { - if v, ok := s.(float64); ok { - statusID = int(v) - } - } - - // Extract subject - subject, _ := issue["subject"].(string) - - // Extract assignee - assignee := "" - if assigned, ok := issue["assigned_to"]; ok { - if a, ok := assigned.(map[string]interface{}); ok { - if login, ok := a["login"].(string); ok { - assignee = login - } else if name, ok := a["name"].(string); ok { - assignee = name - } - } - } - - item := map[string]interface{}{ - "number": number, - "subject": subject, - "assignee": assignee, - } - - if _, exists := columns[statusID]; !exists { - columns[statusID] = make([]map[string]interface{}, 0) - statusOrder = append(statusOrder, statusID) - } - columns[statusID] = append(columns[statusID], item) - } - - // Sort status IDs - sort.Ints(statusOrder) - - // Build output - boardColumns := make([]map[string]interface{}, 0) - for _, sid := range statusOrder { - name, ok := knownStatuses[sid] - if !ok { - name = fmt.Sprintf("Status %d", sid) - } - boardColumns = append(boardColumns, map[string]interface{}{ - "id": sid, - "name": name, - "issues": columns[sid], - "count": len(columns[sid]), - }) - } - - total := 0 - for _, col := range boardColumns { - total += col["count"].(int) - } - - return ctx.OutputData(map[string]interface{}{ - "columns": boardColumns, - "total": total, - }) -} - -func runColumns(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - // Try fetching from API first - env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/issue_statues", nil) - if err == nil { - return ctx.Output(env) - } - - // Fallback to known statuses - type statusInfo struct { - ID int `json:"id"` - Name string `json:"name"` - } - statuses := make([]statusInfo, 0) - for id, name := range knownStatuses { - statuses = append(statuses, statusInfo{ID: id, Name: name}) - } - sort.Slice(statuses, func(i, j int) bool { - return statuses[i].ID < statuses[j].ID - }) - - return ctx.OutputData(map[string]interface{}{ - "statuses": statuses, - }) -} - -func runMove(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - number, err := ctx.RequireArg("number") - if err != nil { - return err - } - state := ctx.Arg("state") - if state == "" { - return fmt.Errorf("required flag --state is missing") - } - - // Resolve status_id from state string or numeric - var statusID int - if id, err := strconv.Atoi(state); err == nil { - statusID = id - } else if id, ok := statusNameToID[strings.ToLower(strings.TrimSpace(state))]; ok { - statusID = id - } else { - return fmt.Errorf("invalid --state %q: use open, in_progress, resolved, closed, or a numeric status_id", state) - } - - // Fetch current issue to preserve subject and description - current, err := fetchIssue(ctx, number) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - // Update the issue status - payload := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - "status_id": statusID, - } - - env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -// --- Helpers --- - -type existingIssue struct { - Subject string - Description string -} - -func fetchIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { - getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) - if err != nil { - return nil, err - } - data, ok := getEnv.Data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("failed to parse issue data") - } - subject, _ := data["subject"].(string) - description, _ := data["description"].(string) - return &existingIssue{ - Subject: subject, - Description: description, - }, nil -} diff --git a/shortcuts/board/board_test.go b/shortcuts/board/board_test.go deleted file mode 100644 index 98339bc..0000000 --- a/shortcuts/board/board_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package board - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -// --- Test helpers --- - -func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findBoardShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "test-owner", - Repo: "test-repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findBoardShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func newBoardTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) - } -} - -func assertQueryParam(t *testing.T, r *http.Request, key, expected string) { - t.Helper() - actual := r.URL.Query().Get(key) - if actual != expected { - t.Fatalf("query param %q = %q, want %q", key, actual, expected) - } -} - -// --- Tests --- - -func TestBoardView(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json") - assertQueryParam(t, r, "limit", "100") - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{ - "project_issues_index": float64(1), - "subject": "Fix login bug", - "status_id": float64(1), - }, - map[string]interface{}{ - "project_issues_index": float64(2), - "subject": "Add dark mode", - "status_id": float64(2), - "assigned_to": map[string]interface{}{ - "login": "dev1", - }, - }, - map[string]interface{}{ - "project_issues_index": float64(3), - "subject": "Update docs", - "status_id": float64(5), - }, - }, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", nil) - if err != nil { - t.Fatalf("board view failed: %v", err) - } -} - -func TestBoardViewWithStateFilter(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json") - assertQueryParam(t, r, "state", "closed") - writeJSON(t, w, map[string]interface{}{ - "total_count": 1, - "issues": []interface{}{ - map[string]interface{}{ - "project_issues_index": float64(3), - "subject": "Closed issue", - "status_id": float64(5), - }, - }, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", map[string]string{ - "state": "closed", - }) - if err != nil { - t.Fatalf("board view with state filter failed: %v", err) - } -} - -func TestBoardViewWithMilestoneFilter(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json") - assertQueryParam(t, r, "fixed_version_id", "42") - writeJSON(t, w, map[string]interface{}{ - "total_count": 0, - "issues": []interface{}{}, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", map[string]string{ - "milestone": "42", - }) - if err != nil { - t.Fatalf("board view with milestone filter failed: %v", err) - } -} - -func TestBoardViewEmpty(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "total_count": 0, - "issues": []interface{}{}, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", nil) - if err != nil { - t.Fatalf("board view for empty repo failed: %v", err) - } -} - -func TestBoardColumns(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issue_statues.json") - writeJSON(t, w, map[string]interface{}{ - "statues": []interface{}{ - map[string]interface{}{"id": float64(1), "name": "新增"}, - map[string]interface{}{"id": float64(2), "name": "正在解决"}, - map[string]interface{}{"id": float64(3), "name": "已解决"}, - map[string]interface{}{"id": float64(5), "name": "关闭"}, - }, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "columns", nil) - if err != nil { - t.Fatalf("board columns failed: %v", err) - } -} - -func TestBoardColumnsFallback(t *testing.T) { - // Server returns 404, which should trigger the fallback to known statuses - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - writeJSON(t, w, map[string]interface{}{"message": "not found"}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "columns", nil) - if err != nil { - t.Fatalf("board columns fallback failed: %v", err) - } -} - -func TestBoardMove(t *testing.T) { - callCount := 0 - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - // First call: fetch current issue - assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues/5.json") - writeJSON(t, w, map[string]interface{}{ - "subject": "Test issue", - "description": "Test description", - }) - return - } - // Second call: update issue status - assertRequest(t, r, "PATCH", "/v1/test-owner/test-repo/issues/5.json") - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode payload: %v", err) - } - if payload["status_id"] != float64(2) { - t.Fatalf("status_id = %v, want 2", payload["status_id"]) - } - writeJSON(t, w, map[string]interface{}{"id": 5, "status_id": 2}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "5", - "state": "in_progress", - }) - if err != nil { - t.Fatalf("board move failed: %v", err) - } - if callCount != 2 { - t.Fatalf("expected 2 API calls, got %d", callCount) - } -} - -func TestBoardMoveWithChineseState(t *testing.T) { - callCount := 0 - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - writeJSON(t, w, map[string]interface{}{ - "subject": "Test", - "description": "Test", - }) - return - } - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["status_id"] != float64(5) { - t.Fatalf("status_id = %v, want 5", payload["status_id"]) - } - writeJSON(t, w, map[string]interface{}{}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "1", - "state": "关闭", - }) - if err != nil { - t.Fatalf("board move with Chinese state failed: %v", err) - } -} - -func TestBoardMoveRejectsMissingNumber(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "state": "closed", - }) - if err == nil { - t.Fatal("expected error when --number is missing") - } -} - -func TestBoardMoveRejectsMissingState(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "1", - }) - if err == nil { - t.Fatal("expected error when --state is missing") - } -} - -func TestBoardMoveRejectsInvalidState(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "1", - "state": "invalid_state_name", - }) - if err == nil { - t.Fatal("expected error for invalid state") - } -} - -func TestBoardMoveWithNumericState(t *testing.T) { - callCount := 0 - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - writeJSON(t, w, map[string]interface{}{ - "subject": "Test", - "description": "Test", - }) - return - } - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["status_id"] != float64(3) { - t.Fatalf("status_id = %v, want 3", payload["status_id"]) - } - writeJSON(t, w, map[string]interface{}{}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "1", - "state": "3", - }) - if err != nil { - t.Fatalf("board move with numeric state failed: %v", err) - } -} - -func TestBoardViewHandlesAPIError(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - writeJSON(t, w, map[string]interface{}{"message": "not found"}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", nil) - if err == nil { - t.Fatal("expected error when API returns 404") - } -} - -func TestBoardMoveHandlesAPIFetchError(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - writeJSON(t, w, map[string]interface{}{"message": "issue not found"}) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "move", map[string]string{ - "number": "999", - "state": "closed", - }) - if err == nil { - t.Fatal("expected error when issue does not exist") - } -} - -func TestBoardViewGroupsIssuesCorrectly(t *testing.T) { - server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "issues": []interface{}{ - map[string]interface{}{ - "project_issues_index": float64(1), - "subject": "Bug A", - "status_id": float64(1), - }, - map[string]interface{}{ - "project_issues_index": float64(2), - "subject": "Bug B", - "status_id": float64(1), - }, - map[string]interface{}{ - "project_issues_index": float64(3), - "subject": "Task A", - "status_id": float64(2), - }, - map[string]interface{}{ - "project_issues_index": float64(4), - "subject": "Done A", - "status_id": float64(5), - }, - }, - }) - }) - defer server.Close() - - err := runBoardShortcut(t, server, "view", nil) - if err != nil { - t.Fatalf("board view grouping failed: %v", err) - } -} diff --git a/shortcuts/register.go b/shortcuts/register.go index 77716bf..adf5a57 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/shortcuts/attachment" - "github.com/gitlink-org/gitlink-cli/shortcuts/board" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/collaborator" @@ -44,7 +43,6 @@ func RegisterAll(root *cobra.Command) { "ci": ci.Shortcuts(), "webhook": webhook.Shortcuts(), "wiki": wiki.Shortcuts(), - "board": board.Shortcuts(), "snippet": snippet.Shortcuts(), "collaborator": collaborator.Shortcuts(), "tag": tag.Shortcuts(), @@ -71,7 +69,6 @@ func RegisterAll(root *cobra.Command) { "ci": "CI/CD operations", "webhook": "Webhook operations", "wiki": "Wiki operations", - "board": "Project board (kanban) operations", "snippet": "Code snippet management", "collaborator": "Collaborator management", "tag": "Git tag operations",