feat: add milestone, compare, pr reopen shortcuts and api enhancements (#45)

- milestone shortcuts: list, create, view, update, delete, close, reopen
- compare shortcuts: view, files (compare branches/tags/commits)
- pr +reopen: reopen closed pull requests
- api: add --body-file and --body-stdin for reading JSON from file/stdin
- comprehensive tests for all new shortcuts

Co-authored-by: wangyue111 <wangyue111>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wbtiger 2026-05-27 00:44:39 +08:00
parent 36249f3087
commit 898b7f59d1
13 changed files with 993 additions and 215 deletions

View File

@ -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 == "" {

90
cmd/api/api_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "创建了<b>合并请求</b>",
"created_at": "2026-05-24 21:43",
},
{
"operate_category": "status",
"operate_content": "<b>拒绝了</b>合并请求",
"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 {

View File

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

View File

@ -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`.

View File

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

View File

@ -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 <id>` first to confirm the PR is currently closed.
- This command uses the PR number shown in the web URL, not the internal database ID.