diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..d13cf52 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -3,7 +3,9 @@ package api import ( "encoding/json" "fmt" + "io" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -20,12 +22,15 @@ func NewAPICmd() *cobra.Command { Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' + gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`, Args: cobra.ExactArgs(2), RunE: runAPI, } apiCmd.Flags().String("body", "", "Request body (JSON string)") + apiCmd.Flags().String("body-file", "", "Read request body JSON from a file") + apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin") apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") @@ -46,12 +51,9 @@ func runAPI(c *cobra.Command, args []string) error { } cli.Debug = cmdutil.Debug - var body interface{} - bodyStr, _ := c.Flags().GetString("body") - if bodyStr != "" { - if err := json.Unmarshal([]byte(bodyStr), &body); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) - } + body, err := readJSONBody(c) + if err != nil { + return err } var query url.Values @@ -76,6 +78,49 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +func readJSONBody(c *cobra.Command) (interface{}, error) { + bodyStr, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + bodyStdin, _ := c.Flags().GetBool("body-stdin") + + sources := 0 + if bodyStr != "" { + sources++ + } + if bodyFile != "" { + sources++ + } + if bodyStdin { + sources++ + } + if sources == 0 { + return nil, nil + } + if sources > 1 { + return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin") + } + + var data []byte + var err error + switch { + case bodyStr != "": + data = []byte(bodyStr) + case bodyFile != "": + data, err = os.ReadFile(bodyFile) + case bodyStdin: + data, err = io.ReadAll(c.InOrStdin()) + } + if err != nil { + return nil, fmt.Errorf("read JSON body: %w", err) + } + + var body interface{} + if err := json.Unmarshal(data, &body); err != nil { + return nil, fmt.Errorf("invalid JSON body: %w", err) + } + return body, nil +} + func resolveFormat() string { f := cmdutil.Format if f == "" { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 0000000..df9a104 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadJSONBodyFromInlineFlag(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello","count":2}`) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["title"] != "hello" { + t.Fatalf("title = %v, want hello", values["title"]) + } + if values["count"] != float64(2) { + t.Fatalf("count = %v, want 2", values["count"]) + } +} + +func TestReadJSONBodyFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "body.json") + if err := os.WriteFile(path, []byte(`{"description":"来自文件"}`), 0o600); err != nil { + t.Fatalf("write body file: %v", err) + } + + cmd := NewAPICmd() + cmd.Flags().Set("body-file", path) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["description"] != "来自文件" { + t.Fatalf("description = %v, want 来自文件", values["description"]) + } +} + +func TestReadJSONBodyFromStdin(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body-stdin", "true") + cmd.SetIn(strings.NewReader(`{"notes":"from stdin"}`)) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["notes"] != "from stdin" { + t.Fatalf("notes = %v, want from stdin", values["notes"]) + } +} + +func TestReadJSONBodyRejectsMultipleSources(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello"}`) + cmd.Flags().Set("body-stdin", "true") + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected multiple body sources to return an error") + } +} + +func TestReadJSONBodyRejectsInvalidJSON(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":`) + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected invalid JSON to return an error") + } +} + +func TestReadJSONBodyWithoutSource(t *testing.T) { + cmd := NewAPICmd() + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + if body != nil { + t.Fatalf("body = %v, want nil", body) + } +} diff --git a/doc/changes/milestone-shortcut.md b/doc/changes/milestone-shortcut.md new file mode 100644 index 0000000..a0e569b --- /dev/null +++ b/doc/changes/milestone-shortcut.md @@ -0,0 +1,19 @@ +# Milestone shortcut + +新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装: + +- `milestone +list` +- `milestone +create` +- `milestone +view` +- `milestone +update` +- `milestone +delete` +- `milestone +close` +- `milestone +reopen` + +实现要点: + +- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。 +- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。 +- `+update` 在没有任何变更字段时直接报错,避免发送空更新。 +- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。 +- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。 diff --git a/shortcuts/compare/compare.go b/shortcuts/compare/compare.go new file mode 100644 index 0000000..b39937a --- /dev/null +++ b/shortcuts/compare/compare.go @@ -0,0 +1,83 @@ +package compare + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "view", + Description: "Compare two branches, tags, or commits", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List changed files between two refs", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + {Name: "file", Short: "f", Usage: "Filter by file path"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if file := ctx.Arg("file"); file != "" { + q.Set("filepath", file) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func comparePath(ctx *common.RuntimeContext, head, base string) string { + return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base)) +} + +func encodeRef(ref string) string { + return base64.RawURLEncoding.EncodeToString([]byte(ref)) +} diff --git a/shortcuts/compare/compare_test.go b/shortcuts/compare/compare_test.go new file mode 100644 index 0000000..d10b9f5 --- /dev/null +++ b/shortcuts/compare/compare_test.go @@ -0,0 +1,107 @@ +package compare + +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" +) + +func TestCompareViewEncodesRefs(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledPath = r.URL.Path + if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"commits_count": 1}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "view", map[string]string{ + "head": "feature/api", + "base": "master", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if calledPath == "" { + t.Fatal("server was not called") + } +} + +func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" { + t.Fatalf("filepath query = %q, want cmd/api/api.go", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Fatalf("limit query = %q, want 50", got) + } + writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "files", map[string]string{ + "head": "bugfix", + "base": "release/v1", + "file": "cmd/api/api.go", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("files shortcut failed: %v", err) + } +} + +func TestEncodeRefUsesRawURLBase64(t *testing.T) { + got := encodeRef("feature/api") + want := "ZmVhdHVyZS9hcGk" + if got != want { + t.Fatalf("encodeRef() = %q, want %q", got, want) + } +} + +func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findCompareShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findCompareShortcut(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 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) + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 0000000..1ec2b30 --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -0,0 +1,239 @@ +package milestone + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List milestones", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "category", Short: "c", Usage: "Filter by category: opening, closed"}, + {Name: "only-name", Usage: "Return only milestone id and name: true or false"}, + {Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a milestone", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, + {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := milestonePayload(ctx, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestonePath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View milestone details and linked issues", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"}, + {Name: "author-id", Usage: "Filter issues by author ID"}, + {Name: "assigner-id", Usage: "Filter issues by assignee ID"}, + {Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "author_id", ctx.Arg("author-id")) + setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id")) + setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids"))) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "name", Short: "n", Usage: "Milestone name"}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + payload, err := milestonePayload(ctx, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + newStatusShortcut("close", "Close a milestone", "closed"), + newStatusShortcut("reopen", "Reopen a milestone", "open"), + } +} + +func milestonePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo) +} + +func milestoneItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id)) +} + +func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) +} + +func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { + payload := map[string]interface{}{} + if name := ctx.Arg("name"); name != "" { + payload["name"] = name + } + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + payload["effective_date"] = dueDate + } + + if requireAll { + for _, name := range []string{"name", "description", "due-date"} { + if _, err := ctx.RequireArg(name); err != nil { + return nil, err + } + } + return payload, nil + } + if len(payload) == 0 { + return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required") + } + return payload, nil +} + +func newStatusShortcut(name, description, status string) *common.Shortcut { + return &common.Shortcut{ + Name: name, + Description: description, + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{ + "status": status, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func normalizeCSV(value string) string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return strings.Join(result, ",") +} diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go new file mode 100644 index 0000000..bf76193 --- /dev/null +++ b/shortcuts/milestone/milestone_test.go @@ -0,0 +1,207 @@ +package milestone + +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" +) + +func TestMilestoneList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json") + assertEqual(t, r.URL.Query().Get("category"), "opening") + assertEqual(t, r.URL.Query().Get("keyword"), "v1") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "list", map[string]string{ + "category": "opening", + "keyword": "v1", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMilestoneCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "create", map[string]string{ + "name": "v1.0", + "description": "first release", + "due-date": "2026-07-01", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, payload["name"], "v1.0") + assertEqual(t, payload["description"], "first release") + assertEqual(t, payload["effective_date"], "2026-07-01") +} + +func TestMilestoneViewWithIssueFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json") + assertEqual(t, r.URL.Query().Get("category"), "opened") + assertEqual(t, r.URL.Query().Get("author_id"), "11") + assertEqual(t, r.URL.Query().Get("assigner_id"), "22") + assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3") + writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "view", map[string]string{ + "id": "7", + "category": "opened", + "author-id": "11", + "assigner-id": "22", + "issue-tag-ids": "1, 2,3", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestMilestoneUpdatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{ + "id": "7", + "due-date": "2026-08-01", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + if _, ok := payload["name"]; ok { + t.Fatal("update payload should omit empty name") + } + assertEqual(t, payload["effective_date"], "2026-08-01") +} + +func TestMilestoneUpdateRequiresChange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected update without fields to return an error") + } +} + +func TestMilestoneDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestMilestoneCloseAndReopen(t *testing.T) { + gotStatuses := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json") + payload := decodeJSON(t, r) + gotStatuses = append(gotStatuses, payload["status"].(string)) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, gotStatuses[0], "closed") + assertEqual(t, gotStatuses[1], "open") +} + +func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMilestoneShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMilestoneShortcut(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 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 decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +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 assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index bf2b628..ce7453a 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,7 +3,6 @@ package pr import ( "fmt" "net/url" - "strings" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -85,9 +84,6 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - if err := enrichPullRequestClosedAt(ctx, env); err != nil { - return err - } return ctx.Output(env) }, }, @@ -135,6 +131,27 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "reopen", + Description: "Reopen a closed pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "files", Description: "List changed files in a pull request", @@ -374,93 +391,3 @@ func extractIssueID(env *output.Envelope) (int64, error) { } return int64(idFloat), nil } - -func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil - } - pr, ok := data["pull_request"].(map[string]interface{}) - if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" { - return nil - } - issue, ok := data["issue"].(map[string]interface{}) - if !ok { - return nil - } - issueID, ok := numberField(issue, "id") - if !ok { - return nil - } - journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil) - if err != nil { - return err - } - closedAt := extractPullRequestClosedAt(journalsEnv) - if closedAt == "" { - return nil - } - pr["closed_at"] = closedAt - data["closed_at"] = closedAt - return nil -} - -func isClosedPullRequest(pr map[string]interface{}) bool { - if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" { - return true - } - status, ok := numberField(pr, "status") - return ok && int(status) == 2 -} - -func extractPullRequestClosedAt(env *output.Envelope) string { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return "" - } - rawJournals, ok := data["journals"].([]interface{}) - if !ok { - return "" - } - for i := len(rawJournals) - 1; i >= 0; i-- { - journal, ok := rawJournals[i].(map[string]interface{}) - if !ok || stringField(journal, "operate_category") != "status" { - continue - } - content := stringField(journal, "operate_content") - if !isPullRequestCloseOperation(content) { - continue - } - if updatedAt := stringField(journal, "updated_at"); updatedAt != "" { - return updatedAt - } - if createdAt := stringField(journal, "created_at"); createdAt != "" { - return createdAt - } - } - return "" -} - -func isPullRequestCloseOperation(content string) bool { - content = strings.ToLower(content) - return strings.Contains(content, "合并请求") && - (strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed")) -} - -func stringField(m map[string]interface{}, key string) string { - v, _ := m[key].(string) - return v -} - -func numberField(m map[string]interface{}, key string) (float64, bool) { - switch v := m[key].(type) { - case float64: - return v, true - case int: - return float64(v), true - case int64: - return float64(v), true - default: - return 0, false - } -} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index 90e5a07..551bc8d 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -54,87 +53,6 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { assertEqual(t, journalPayload["notes"], "LGTM, looks good!") } -func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) { - var issueJournalCalled bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json": - writeJSON(t, w, map[string]interface{}{ - "issue": map[string]interface{}{ - "id": float64(142756), - }, - "pull_request": map[string]interface{}{ - "status": float64(2), - "pull_request_staus": "closed", - }, - }) - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json": - issueJournalCalled = true - writeJSON(t, w, map[string]interface{}{ - "journals": []map[string]interface{}{ - { - "operate_category": "pull_request", - "operate_content": "创建了合并请求", - "created_at": "2026-05-24 21:43", - }, - { - "operate_category": "status", - "operate_content": "拒绝了合并请求", - "created_at": "2026-05-25 08:58", - "updated_at": "2026-05-25 08:58", - }, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ - "id": "37", - }) - if err != nil { - t.Fatalf("view shortcut failed: %v", err) - } - if !issueJournalCalled { - t.Fatal("issue journal endpoint was not called") - } - data := env.Data.(map[string]interface{}) - assertEqual(t, data["closed_at"], "2026-05-25 08:58") - prData := data["pull_request"].(map[string]interface{}) - assertEqual(t, prData["closed_at"], "2026-05-25 08:58") -} - -func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{ - "issue": map[string]interface{}{ - "id": float64(142793), - }, - "pull_request": map[string]interface{}{ - "status": float64(0), - "pull_request_staus": "open", - }, - }) - })) - defer server.Close() - - env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ - "id": "45", - }) - if err != nil { - t.Fatalf("view shortcut failed: %v", err) - } - data := env.Data.(map[string]interface{}) - if _, ok := data["closed_at"]; ok { - t.Fatal("open PR should not include closed_at") - } -} - func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -345,42 +263,43 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } } -func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - _, err := runPRShortcutWithOutput(t, server, name, args) - return err +func TestPRReopenUsesV1Endpoint(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + calledPath = r.URL.Path + writeJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "reopen", map[string]string{ + "id": "13", + }) + if err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json") } -func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) { +func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) - client := &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - } ctx := &common.RuntimeContext{ - Client: client, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - err := shortcut.Run(ctx) - if err != nil { - return nil, err - } - if name != "view" { - return nil, nil - } - id := args["id"] - env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil) - if err != nil { - return nil, err - } - if err := enrichPullRequestClosedAt(ctx, env); err != nil { - return nil, err - } - return env, nil + return shortcut.Run(ctx) } func findPRShortcut(t *testing.T, name string) *common.Shortcut { diff --git a/shortcuts/register.go b/shortcuts/register.go index 91cf569..49b20ed 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,8 +6,10 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/member" + "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -22,30 +24,34 @@ import ( func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), - "member": member.Shortcuts(), - "pr": pr.Shortcuts(), + "issue": issue.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pr": pr.Shortcuts(), "release": release.Shortcuts(), "branch": branch.Shortcuts(), "org": org.Shortcuts(), "user": user.Shortcuts(), "search": search.Shortcuts(), "ci": ci.Shortcuts(), + "compare": compare.Shortcuts(), "webhook": webhook.Shortcuts(), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ "repo": "Repository operations", - "issue": "Issue operations", - "member": "Repository member operations", - "pr": "Pull request operations", + "issue": "Issue operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pr": "Pull request operations", "release": "Release operations", "branch": "Branch operations", "org": "Organization operations", "user": "User operations", "search": "Search operations", "ci": "CI/CD operations", + "compare": "Compare branches, tags, or commits", "webhook": "Webhook operations", "workflow": "AI agent workflow analysis", } diff --git a/skills/gitlink-compare/SKILL.md b/skills/gitlink-compare/SKILL.md new file mode 100644 index 0000000..af7dbe9 --- /dev/null +++ b/skills/gitlink-compare/SKILL.md @@ -0,0 +1,40 @@ +--- +name: gitlink-compare +version: 1.0.0 +description: "Compare GitLink branches, tags, or commits and inspect changed files." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli compare --help" +--- + +# gitlink-compare + +Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior. + +## Shortcuts + +| Shortcut | Description | +|----------|-------------| +| `compare +view` | Compare two branches, tags, or commits | +| `compare +files` | List changed files between two refs | + +## Examples + +```bash +# Compare two refs and include commit/diff summary +gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/api --base master + +# List changed files +gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/api --base master + +# Filter a single file in the file diff endpoint +gitlink-cli compare +files --owner Gitlink --repo forgeplus \ + --head feature/api --base master --file cmd/api/api.go +``` + +## Notes + +- Pass normal branch, tag, or commit names. The CLI base64-url encodes refs before calling GitLink compare endpoints. +- `compare +view` calls `/api/{owner}/{repo}/compare/{head}...{base}`. +- `compare +files` calls `/api/v1/{owner}/{repo}/compare/{head}...{base}/files`. diff --git a/skills/gitlink-milestone/SKILL.md b/skills/gitlink-milestone/SKILL.md new file mode 100644 index 0000000..cd77948 --- /dev/null +++ b/skills/gitlink-milestone/SKILL.md @@ -0,0 +1,66 @@ +--- +name: gitlink-milestone +version: 1.0.0 +description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli milestone --help" +--- + +# gitlink-milestone + +**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. +**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`. +**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. + +## Shortcuts + +| Shortcut | Description | Operation | +|----------|-------------|-----------| +| `milestone +list` | List repository milestones | Read | +| `milestone +create` | Create a milestone | Write | +| `milestone +view` | View milestone details and linked issues | Read | +| `milestone +update` | Update milestone fields | Write | +| `milestone +delete` | Delete a milestone | Destructive | +| `milestone +close` | Close a milestone | Write | +| `milestone +reopen` | Reopen a closed milestone | Write | + +## Examples + +```bash +# List open milestones +gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening + +# Create a milestone +gitlink-cli milestone +create --owner Gitlink --repo forgeplus \ + --name v1.0 --description "First stable release" --due-date 2026-07-01 + +# View milestone details and linked opened issues +gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened + +# Update the due date +gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01 + +# Close and reopen +gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7 +gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7 +``` + +## Parameters + +| Command | Key parameters | +|---------|----------------| +| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` | +| `+create` | `--name`, `--description`, `--due-date` | +| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` | +| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` | +| `+delete` | `--id` | +| `+close` / `+reopen` | `--id` | + +## API Notes + +- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`. +- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`. +- `--due-date` maps to the GitLink API field `effective_date`. +- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API. diff --git a/skills/gitlink-pr/references/gitlink-pr-reopen.md b/skills/gitlink-pr/references/gitlink-pr-reopen.md new file mode 100644 index 0000000..6c2505e --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-reopen.md @@ -0,0 +1,30 @@ +# pr +reopen + +Reopen a closed Pull Request. + +## Usage + +```bash +gitlink-cli pr +reopen --id 3 +gitlink-cli pr +reopen -i 3 +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--id` / `-i` | Yes | PR number from the web URL `/pulls/N` | +| `--owner` | No | Repository owner, auto-detected from git remote when omitted | +| `--repo` | No | Repository name, auto-detected from git remote when omitted | +| `--format` | No | Output format: `json`, `table`, or `yaml` | + +## API + +```text +POST /v1/{owner}/{repo}/pulls/{number}/reopen +``` + +## Notes + +- Use `pr +view -i ` first to confirm the PR is currently closed. +- This command uses the PR number shown in the web URL, not the internal database ID.