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.