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:
parent
36249f3087
commit
898b7f59d1
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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。
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ",")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue