remove: project board shortcut (duplicated with issue commands)
CI / test (push) Failing after 2m7s Details

This commit is contained in:
赵昌 2026-06-04 16:03:53 +08:00
parent 811bae1cd5
commit 59552d9286
3 changed files with 0 additions and 702 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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",