diff --git a/.devops/ci.yml b/.devops/ci.yml new file mode 100644 index 0000000..5acd27d --- /dev/null +++ b/.devops/ci.yml @@ -0,0 +1,41 @@ +version: 2 +name: gitlink_cli_ci +description: "gitlink-cli 代码提交时自动执行 CI 检查(构建、测试、格式化)" +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND +global: + concurrent: 1 +workflow: + - ref: start + name: 开始 + task: start + - ref: git_clone_0 + name: 拉取代码 + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/wyx_branch"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: CI 检查 + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((gitlink_cli_ci.ssh_pass)) + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b wyx_branch https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && go version && go build ./... && go vet ./... && go test -race ./... && output=$(gofmt -s -l .) && if [ -n \"$output\" ]; then echo '格式化检查失败:' && echo \"$output\" && exit 1; fi && echo '所有 CI 检查通过'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0862b9e..cc4a865 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.26.1' - name: Build run: go build ./... diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go index a218c79..059b052 100644 --- a/cmd/alias/alias.go +++ b/cmd/alias/alias.go @@ -3,14 +3,11 @@ package alias import ( "fmt" "os" - "sort" "github.com/spf13/cobra" "gopkg.in/yaml.v3" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/config" - "github.com/gitlink-org/gitlink-cli/internal/output" ) // AliasConfig represents the aliases section of the CLI config. @@ -43,32 +40,13 @@ func NewAliasCmd() *cobra.Command { Long: "列出所有已定义的命令别名。如果没有任何别名,会给出创建提示。", RunE: func(cmd *cobra.Command, args []string) error { aliases, _ := loadAliases() - // Structured formats (json/yaml/table) route through output.Print - // so alias +list integrates with scripts and AI Agents. - if cmdutil.Format == "json" || cmdutil.Format == "yaml" || cmdutil.Format == "table" { - rows := make([]map[string]string, 0, len(aliases)) - names := make([]string, 0, len(aliases)) - for k := range aliases { - names = append(names, k) - } - sort.Strings(names) - for _, k := range names { - rows = append(rows, map[string]string{"name": k, "command": aliases[k]}) - } - return output.Print(output.SuccessEnvelope(rows, nil), cmdutil.Format) - } if len(aliases) == 0 { fmt.Println("(未定义任何别名)") fmt.Println("使用 alias +set <名称> <命令> 来创建别名") return nil } - names := make([]string, 0, len(aliases)) - for k := range aliases { - names = append(names, k) - } - sort.Strings(names) - for _, k := range names { - fmt.Printf(" %-15s → %s\n", k, aliases[k]) + for k, v := range aliases { + fmt.Printf(" %-15s → %s\n", k, v) } return nil }, @@ -108,23 +86,6 @@ func NewAliasCmd() *cobra.Command { return nil }, }, - &cobra.Command{ - Use: "+expand ", - Short: "展开别名查看原命令", - Long: "查看一个别名对应的原始命令。如果别名不存在则报错。", - Args: cobra.ExactArgs(1), - Example: ` gitlink-cli alias +expand rl - 输出: rl → repo +list`, - RunE: func(cmd *cobra.Command, args []string) error { - aliases, _ := loadAliases() - expanded, ok := aliases[args[0]] - if !ok { - return fmt.Errorf("别名 %s 不存在", args[0]) - } - fmt.Printf("%s → %s\n", args[0], expanded) - return nil - }, - }, ) return cmd } diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go index ebf2ccd..0c54cd6 100644 --- a/cmd/alias/alias_test.go +++ b/cmd/alias/alias_test.go @@ -1,15 +1,16 @@ package alias import ( - "io" + "bytes" "os" "strings" "testing" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/spf13/cobra" ) func TestLoadAliasesEmpty(t *testing.T) { + // 设置临时配置目录 tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) @@ -26,6 +27,7 @@ func TestSaveAndLoadAliases(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + // 保存 original := map[string]string{ "rl": "repo +list", "ri": "repo +info", @@ -34,6 +36,7 @@ func TestSaveAndLoadAliases(t *testing.T) { t.Fatalf("saveAliases failed: %v", err) } + // 加载 loaded, err := loadAliases() if err != nil { t.Fatalf("loadAliases failed: %v", err) @@ -53,7 +56,10 @@ func TestSaveAliasesOverwrite(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + // 第一次保存 saveAliases(map[string]string{"rl": "repo +list"}) + + // 覆盖保存 saveAliases(map[string]string{"rl": "repo +list --owner Gitlink"}) loaded, _ := loadAliases() @@ -66,6 +72,7 @@ func TestLoadAliasesInvalidYAML(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + // 写入无效 YAML os.WriteFile(tmpDir+"/aliases.yaml", []byte("{{invalid yaml}}"), 0600) aliases, err := loadAliases() @@ -77,7 +84,7 @@ func TestLoadAliasesInvalidYAML(t *testing.T) { } } -func TestNewAliasCmdStructure(t *testing.T) { +func TestNewAliasCmd(t *testing.T) { cmd := NewAliasCmd() if cmd.Use != "alias" { t.Errorf("expected Use 'alias', got %s", cmd.Use) @@ -85,59 +92,80 @@ func TestNewAliasCmdStructure(t *testing.T) { if !cmd.HasSubCommands() { t.Error("alias command should have subcommands") } - subcmds := cmd.Commands() - if len(subcmds) != 4 { - t.Fatalf("expected 4 subcommands, got %d", len(subcmds)) - } - - expectedUses := map[string]bool{"+list": false, "+set ": false, "+delete ": false, "+expand ": false} - for _, sub := range subcmds { - if _, ok := expectedUses[sub.Use]; ok { - expectedUses[sub.Use] = true - } - } - for use, found := range expectedUses { - if !found { - t.Errorf("subcommand %q not found", use) - } + if len(subcmds) != 3 { + t.Fatalf("expected 3 subcommands, got %d", len(subcmds)) } } -func TestAliasSetAndDeleteFlow(t *testing.T) { +func TestAliasListSubcommand(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - // 模拟 +set 操作:直接调用 saveAliases - aliases := make(map[string]string) - aliases["rl"] = "repo +list" - aliases["ri"] = "repo +info" - if err := saveAliases(aliases); err != nil { - t.Fatalf("saveAliases failed: %v", err) + cmd := NewAliasCmd() + // 找到 +list 子命令 + var listCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Use == "+list" { + listCmd = sub + break + } + } + if listCmd == nil { + t.Fatal("+list subcommand not found") } - // 验证保存成功 - loaded, _ := loadAliases() - if loaded["rl"] != "repo +list" { - t.Fatalf("alias not saved correctly: %v", loaded) + // 无别名时运行 + buf := new(bytes.Buffer) + listCmd.SetOut(buf) + listCmd.SetArgs([]string{}) + if err := listCmd.Execute(); err != nil { + t.Fatalf("list failed: %v", err) } - if loaded["ri"] != "repo +info" { - t.Fatalf("alias not saved correctly: %v", loaded) + if !strings.Contains(buf.String(), "未定义任何别名") { + t.Errorf("expected hint for no aliases, got: %s", buf.String()) + } +} + +func TestAliasSetAndDeleteSubcommands(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + cmd := NewAliasCmd() + + // 找到 +set 子命令 + var setCmd, deleteCmd *cobra.Command + for _, sub := range cmd.Commands() { + if strings.HasPrefix(sub.Use, "+set") { + setCmd = sub + } + if strings.HasPrefix(sub.Use, "+delete") { + deleteCmd = sub + } } - // 模拟 +delete 操作:删除别名后保存 - delete(loaded, "rl") - if err := saveAliases(loaded); err != nil { - t.Fatalf("saveAliases after delete failed: %v", err) + // +set + setCmd.SetArgs([]string{"rl", "repo +list"}) + if err := setCmd.Execute(); err != nil { + t.Fatalf("set failed: %v", err) } - // 验证删除成功 - final, _ := loadAliases() - if _, ok := final["rl"]; ok { - t.Fatal("alias 'rl' should have been deleted") + // 验证文件写入 + aliases, _ := loadAliases() + if aliases["rl"] != "repo +list" { + t.Fatalf("alias not saved correctly: %v", aliases) } - if final["ri"] != "repo +info" { - t.Fatal("alias 'ri' should still exist") + + // +delete + deleteCmd.SetArgs([]string{"rl"}) + if err := deleteCmd.Execute(); err != nil { + t.Fatalf("delete failed: %v", err) + } + + // 验证已删除 + aliases, _ = loadAliases() + if _, ok := aliases["rl"]; ok { + t.Fatal("alias should have been deleted") } } @@ -145,80 +173,21 @@ func TestAliasDeleteNonExistent(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - // 空别名列表,删除不存在的别名 - aliases, _ := loadAliases() - if _, ok := aliases["nonexistent"]; ok { - t.Fatal("nonexistent alias should not exist") - } - // 验证逻辑:别名不存在时不应执行删除 - // 这对应 alias.go 中 if _, ok := aliases[args[0]]; !ok 的检查 -} - -func TestAliasesFilePath(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - - expected := tmpDir + "/aliases.yaml" - got := aliasesPath() - if got != expected { - t.Errorf("expected path %s, got %s", expected, got) - } -} - -func TestAliasExpandExisting(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - - saveAliases(map[string]string{ - "rl": "repo +list", - "ri": "repo +info", - }) - - aliases, _ := loadAliases() - if expanded, ok := aliases["rl"]; !ok || expanded != "repo +list" { - t.Fatalf("expected rl → repo +list, got %s", expanded) - } - if expanded, ok := aliases["ri"]; !ok || expanded != "repo +info" { - t.Fatalf("expected ri → repo +info, got %s", expanded) - } -} - -func TestAliasExpandNonExistent(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - - aliases, _ := loadAliases() - if _, ok := aliases["nonexistent"]; ok { - t.Fatal("nonexistent alias should not be found") - } -} - -func TestAliasListJSONFormat(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - if err := saveAliases(map[string]string{"rl": "repo +list", "ri": "repo +info"}); err != nil { - t.Fatalf("save: %v", err) - } - cmdutil.Format = "json" - defer func() { cmdutil.Format = "" }() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - root := NewAliasCmd() - root.SetArgs([]string{"+list"}) - execErr := root.Execute() - w.Close() - os.Stdout = old - if execErr != nil { - t.Fatalf("execute: %v", execErr) - } - var buf strings.Builder - io.Copy(&buf, r) - out := buf.String() - for _, want := range []string{`"ok": true`, `"name"`, `"rl"`, `"repo +list"`} { - if !strings.Contains(out, want) { - t.Errorf("JSON output missing %q: %s", want, out) + cmd := NewAliasCmd() + var deleteCmd *cobra.Command + for _, sub := range cmd.Commands() { + if strings.HasPrefix(sub.Use, "+delete") { + deleteCmd = sub + break } } + + deleteCmd.SetArgs([]string{"nonexistent"}) + err := deleteCmd.Execute() + if err == nil { + t.Fatal("expected error when deleting nonexistent alias") + } + if !strings.Contains(err.Error(), "不存在") { + t.Errorf("error should mention alias does not exist: %v", err) + } } diff --git a/cmd/browse/browse.go b/cmd/browse/browse.go index 776bf05..58400a2 100644 --- a/cmd/browse/browse.go +++ b/cmd/browse/browse.go @@ -2,164 +2,57 @@ package browse import ( "fmt" - "io" - "os" - "strconv" - "strings" + "os/exec" + "runtime" "github.com/spf13/cobra" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/context" - "github.com/gitlink-org/gitlink-cli/internal/output" - "github.com/gitlink-org/gitlink-cli/internal/web" ) -// stdout is the browse command's output target (so tests can redirect). -var stdout io.Writer = os.Stdout - -// browsableKinds maps the first path segment of `browse [/id>` to a URL -// builder. The "default" entry is used as a fallback that appends the raw arg -// to the repo URL, preserving the original passthrough behaviour. -var browsableKinds = []struct { - kind string - desc string -}{ - {"issues", "Issue 列表 / 详情 (issues/42)"}, - {"pulls", "PR 列表 / 详情 (pulls/128)"}, - {"wiki", "Wiki 首页 / 页面 (wiki 或 wiki/API指南)"}, - {"actions", "CI/Actions 页面"}, - {"commits", "提交列表 / 详情 (commits/abc123)"}, - {"branches", "分支列表"}, - {"releases", "Release 列表 / 详情 (releases/v2.0)"}, - {"milestones", "里程碑页面"}, - {"labels", "标签管理页"}, - {"settings/hooks", "Webhook 设置页"}, - {"settings/collaboration", "成员管理页"}, - {"projects", "项目看板页"}, -} - // NewBrowseCmd creates the browse command for opening GitLink pages in a browser. func NewBrowseCmd() *cobra.Command { - var listFlag, noOpen bool - cmd := &cobra.Command{ + return &cobra.Command{ Use: "browse [resource]", Short: "在浏览器中打开 GitLink 页面", Long: `打开当前仓库(或指定资源)的 GitLink 页面。 -资源格式: issues/42, pulls/42, wiki, wiki/页面名, commits/abc123, ... -不带参数则打开仓库主页。owner/repo 自动从 git remote 推断或用 --owner/--repo 指定。 +如果不带参数,打开当前仓库主页。 +资源格式: issues/42, pulls/42, wiki -示例: - gitlink-cli browse - gitlink-cli browse issues/42 - gitlink-cli browse pulls/128 - gitlink-cli browse wiki - gitlink-cli browse --list # 列出所有可浏览页面 - gitlink-cli browse --no-open # 只打印 URL,不打开浏览器`, +浏览器打开命令: + - macOS: open + - Windows: start + - Linux: xdg-open`, Example: ` gitlink-cli browse gitlink-cli browse issues/42 gitlink-cli browse pulls/128 - gitlink-cli browse wiki - gitlink-cli browse --list - gitlink-cli browse --no-open`, + gitlink-cli browse wiki`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) + owner, repo, err := context.ResolveOwnerRepo("", "") if err != nil { return fmt.Errorf("无法推断仓库信息: %w", err) } - - if listFlag { - listBrowsables(owner, repo) - return nil + url := fmt.Sprintf("https://gitlink.org.cn/%s/%s", owner, repo) + if len(args) > 0 { + url += "/" + args[0] } - - rurl := resolveBrowseURL(web.NewBuilder(), owner, repo, args) - emitBrowse(rurl) - if !noOpen { - if err := web.OpenBrowser(rurl.URL); err != nil { - // 打开失败仅告警,URL 已经打印供手动复制 - fmt.Fprintf(stdout, "(浏览器未自动打开: %v;请手动复制上方 URL)\n", err) - } - } - return nil + fmt.Printf("正在打开: %s\n", url) + return openBrowser(url) }, } - cmd.Flags().BoolVar(&listFlag, "list", false, "列出当前仓库所有可浏览的页面") - cmd.Flags().BoolVar(&noOpen, "no-open", false, "只打印 URL,不打开浏览器") - return cmd } -// emitBrowse prints the URL — friendly single line by default, structured -// envelope when --format is set. -func emitBrowse(r *web.ResourceURL) { - if cmdutil.Format == "" { - fmt.Fprintf(stdout, "🔗 %s\n", r.URL) - return - } - _ = output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format) -} - -// listBrowsables prints the catalog of pages `browse` understands. -func listBrowsables(owner, repo string) { - fmt.Fprintf(stdout, "可浏览的 GitLink 页面 (%s/%s):\n", owner, repo) - for _, k := range browsableKinds { - fmt.Fprintf(stdout, " %-28s %s\n", k.kind, k.desc) - } - fmt.Fprintf(stdout, "\n用法: gitlink-cli browse <资源>\n") -} - -// resolveBrowseURL maps `browse ` to a web URL. With no arg → repo home. -func resolveBrowseURL(b *web.Builder, owner, repo string, args []string) *web.ResourceURL { - if len(args) == 0 || args[0] == "" { - return b.RepoURL(owner, repo) - } - arg := strings.TrimPrefix(args[0], "/") - // Split into kind and (optional) rest after the first "/". - kind, rest, _ := strings.Cut(arg, "/") - rest = strings.Trim(rest, "/") - - switch { - case kind == "issues" || kind == "issue": - return b.IssueURL(owner, repo, atoiOrZero(rest)) - case kind == "pulls" || kind == "pr" || kind == "pull": - return b.PRURL(owner, repo, atoiOrZero(rest)) - case kind == "wiki": - return b.WikiURL(owner, repo, rest) - case kind == "actions" || kind == "ci": - return b.CIURL(owner, repo) - case kind == "commits": - return b.CommitURL(owner, repo, rest) - case kind == "branches": - return b.BranchURL(owner, repo, rest) - case kind == "releases": - return b.ReleaseURL(owner, repo, rest) - case kind == "milestones": - return b.MilestoneURL(owner, repo) - case kind == "labels": - return b.LabelURL(owner, repo) - case arg == "settings/hooks": - return b.WebhookURL(owner, repo) - case arg == "settings/collaboration": - return b.MemberURL(owner, repo) - case kind == "settings": - return b.RepoURL(owner, repo) // settings landing falls back to repo home +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) default: - // Unknown resource: append the raw arg as a path segment so behaviour - // stays predictable for callers that already know their URL shape. - return &web.ResourceURL{ - URL: b.RepoURL(owner, repo).URL + "/" + arg, - Resource: "custom", - Identifier: arg, - } + cmd = exec.Command("xdg-open", url) } -} - -func atoiOrZero(s string) int { - n, err := strconv.Atoi(s) - if err != nil { - return 0 - } - return n + return cmd.Start() } diff --git a/cmd/browse/browse_test.go b/cmd/browse/browse_test.go index e2b1715..812e911 100644 --- a/cmd/browse/browse_test.go +++ b/cmd/browse/browse_test.go @@ -1,12 +1,8 @@ package browse import ( - "bytes" "strings" "testing" - - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" - "github.com/gitlink-org/gitlink-cli/internal/web" ) func TestNewBrowseCmd(t *testing.T) { @@ -24,6 +20,7 @@ func TestNewBrowseCmd(t *testing.T) { func TestBrowseCmdHasCorrectArgs(t *testing.T) { cmd := NewBrowseCmd() + // MaximumNArgs(1) should allow 0 or 1 args if err := cmd.Args(cmd, []string{}); err != nil { t.Errorf("should accept 0 args: %v", err) } @@ -35,8 +32,9 @@ func TestBrowseCmdHasCorrectArgs(t *testing.T) { } } -func TestBrowseCmdNoSubcommands(t *testing.T) { +func TestBrowseCmdSubcommandStructure(t *testing.T) { cmd := NewBrowseCmd() + // browse 不应该有子命令 if cmd.HasSubCommands() { t.Error("browse should not have subcommands") } @@ -52,125 +50,9 @@ func TestBrowseCmdExample(t *testing.T) { } } -func TestBrowseCmdHasListAndNoOpenFlags(t *testing.T) { - cmd := NewBrowseCmd() - if cmd.Flags().Lookup("list") == nil { - t.Error("missing --list flag") - } - if cmd.Flags().Lookup("no-open") == nil { - t.Error("missing --no-open flag") - } -} - -func TestResolveBrowseURL(t *testing.T) { - b := web.NewBuilder() - cases := []struct { - name string - args []string - wantSub string - }{ - {"no args → repo", nil, "/o/r"}, - {"issue detail", []string{"issues/42"}, "/issues/42"}, - {"issue alias", []string{"issue/7"}, "/issues/7"}, - {"pr detail", []string{"pulls/128"}, "/pulls/128"}, - {"pr alias", []string{"pr/9"}, "/pulls/9"}, - {"wiki index", []string{"wiki"}, "/wiki"}, - {"wiki page", []string{"wiki/Guide"}, "/wiki/Guide"}, - {"ci", []string{"actions"}, "/actions"}, - {"ci alias", []string{"ci"}, "/actions"}, - {"commit", []string{"commits/abc123"}, "/commits/abc123"}, - {"release", []string{"releases/v2.0"}, "/releases/v2.0"}, - {"milestones", []string{"milestones"}, "/milestones"}, - {"labels", []string{"labels"}, "/issues/labels"}, - {"webhook settings", []string{"settings/hooks"}, "/settings/hooks"}, - {"collaboration", []string{"settings/collaboration"}, "/settings/collaboration"}, - {"unknown passthrough", []string{"custom/seg"}, "/custom/seg"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := resolveBrowseURL(b, "o", "r", c.args) - if !strings.Contains(r.URL, c.wantSub) { - t.Errorf("URL %q missing %q", r.URL, c.wantSub) - } - }) - } -} - -func TestBrowseListOutputsCatalog(t *testing.T) { - out := runBrowse(t, "--owner", "o", "--repo", "r", "--list") - for _, want := range []string{"issues", "pulls", "wiki", "actions"} { - if !strings.Contains(out, want) { - t.Errorf("list missing %q: %q", want, out) - } - } -} - -func TestBrowseJSONFormat(t *testing.T) { - // --format json must route emitBrowse through the output envelope. - out := runBrowseFmt(t, "json", "issues/42") - if !strings.Contains(out, `"html_url"`) { - t.Errorf("JSON browse missing html_url: %q", out) - } -} - -func TestResolveBrowseURLIssueNonNumeric(t *testing.T) { - // atoiOrZero("abc") should fall back to 0 (issue list page). - r := resolveBrowseURL(web.NewBuilder(), "o", "r", []string{"issues/abc"}) - if !strings.HasSuffix(r.URL, "/issues") { - t.Errorf("expected /issues fallback, got %q", r.URL) - } -} - -func TestBrowseNoOpenDoesNotLaunchBrowser(t *testing.T) { - // --no-open must print the URL but never invoke a browser. We can't easily - // stub web.OpenBrowser across packages, so we assert the URL is printed - // and that the "browser did not open" warning (printed only when - // OpenBrowser returns an error) is absent. - out := runBrowseNoOpen(t, "issues/42", true) - if !strings.Contains(out, "/issues/42") { - t.Errorf("expected /issues/42 in output: %q", out) - } - if strings.Contains(out, "浏览器未自动打开") { - t.Errorf("--no-open should not print open-failure warning: %q", out) - } -} - -// runBrowse runs `browse ` with captured stdout. -func runBrowse(t *testing.T, args ...string) string { - t.Helper() - old := stdout - oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format - buf := &bytes.Buffer{} - stdout = buf - defer func() { - stdout = old - cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt - }() - cmd := NewBrowseCmd() - cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "") - cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "") - cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "") - cmd.SetArgs(args) - if err := cmd.Execute(); err != nil { - t.Fatalf("browse %v: %v", args, err) - } - return buf.String() -} - -// runBrowseFmt runs `browse ` with a specific --format value. -func runBrowseFmt(t *testing.T, format, resource string) string { - t.Helper() - return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource) -} - -func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string { - t.Helper() - args := []string{"--owner", "o", "--repo", "r"} - if resource != "" { - args = append(args, resource) - } - if noOpen { - args = append(args, "--no-open") - } - return runBrowse(t, args...) +func TestOpenBrowserReturnsNoError(t *testing.T) { + // openBrowser 在所有平台都应该返回 nil 或一个 error + // 在无头环境下可能会失败,但不应该 panic + _ = openBrowser("https://gitlink.org.cn") + // 只要不 panic 就行 } diff --git a/cmd/root.go b/cmd/root.go index b482452..1e83fb5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,55 +1,133 @@ package cmd import ( + "errors" "fmt" "os" "github.com/spf13/cobra" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" - authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" + aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" + browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse" + statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status" + authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) var Version = "dev" -var rootCmd = &cobra.Command{ - Use: "gitlink-cli", - Short: "GitLink CLI — command-line tool for gitlink.org.cn", - Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`, - SilenceUsage: true, - SilenceErrors: true, +type RootOptions struct { + Version string + Args []string + Env map[string]string + ConfigLang string } -func init() { - rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)") - rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output") +func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { + if tr == nil { + var err error + tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang) + if err != nil { + return nil, err + } + } - rootCmd.AddCommand(authCmd.NewAuthCmd()) - rootCmd.AddCommand(apiCmd.NewAPICmd()) - rootCmd.AddCommand(configCmd.NewConfigCmd()) - rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(newDoCmd()) + version := opts.Version + if version == "" { + version = Version + } - shortcuts.RegisterAll(rootCmd) + rootCmd := &cobra.Command{ + Use: "gitlink-cli", + Short: tr.T("cmd.root.short"), + Long: tr.T("cmd.root.long"), + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) + rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) + + rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) + rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) + rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) + rootCmd.AddCommand(newVersionCmd(version, tr)) + rootCmd.AddCommand(aliasCmd.NewAliasCmd()) + rootCmd.AddCommand(browseCmd.NewBrowseCmd()) + rootCmd.AddCommand(statusCmd.NewStatusCmd()) + + shortcuts.RegisterAll(rootCmd, tr) + + if opts.Args != nil { + rootCmd.SetArgs(opts.Args) + } + return rootCmd, nil } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("gitlink-cli %s\n", Version) - }, +func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: tr.T("cmd.version.short"), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version})) + return err + }, + } } func Execute() error { + args := os.Args[1:] + rootCmd, err := NewRootCmd(RootOptions{ + Version: Version, + Args: args, + }, nil) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err } return nil } + +func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) { + available, err := i18n.AvailableLocales() + if err != nil { + return nil, err + } + if env == nil { + env = i18n.EnvMap() + } + if configLang == "" { + configLang = loadConfigLangBestEffort() + } + resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{ + ExplicitLang: i18n.PreScanLang(args), + Env: env, + ConfigLang: configLang, + }, available) + if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") { + tr := i18n.Default() + return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested})) + } + return i18n.New(i18n.Options{Locale: resolved.Locale}) +} + +func loadConfigLangBestEffort() string { + cfg, err := internalConfig.Load() + if err != nil { + return "" + } + return cfg.Lang +} diff --git a/doc/commands/cross-platform.md b/doc/commands/cross-platform.md new file mode 100644 index 0000000..05ee4c4 --- /dev/null +++ b/doc/commands/cross-platform.md @@ -0,0 +1,47 @@ +# 跨平台兼容性验证报告 + +> 关联 Issue: #16 | PR: #13 + +## 测试矩阵 + +| 验证项 | Windows 11 | macOS | Ubuntu | +|--------|:---:|:---:|:---:| +| `git clone` + `go build ./...` | 待验证 | 待验证 | 待验证 | +| `go test -race ./...` 全部通过 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli auth login` 登录 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli repo +list` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli pm +dashboards` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli wiki +pages` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli alias +list` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli browse` 打开浏览器 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli status` 显示状态 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli export +issues` 导出 | 待验证 | 待验证 | 待验证 | +| Token 存储(keyring)正常 | 待验证 | 待验证 | 待验证 | + +## 安装脚本 + +| 脚本 | 平台 | 路径 | +|------|------|------| +| install.sh | Linux / macOS | `scripts/install.sh` | +| install.ps1 | Windows | `scripts/install.ps1` | + +## CI 配置 + +| 配置文件 | 说明 | +|---------|------| +| `.devops/ci.yml` | 建木流水线:push 到 wyx_branch 时自动触发构建+测试+格式化检查 | + +### CI 检查内容 + +| 检查项 | 命令 | 说明 | +|--------|------|------| +| 构建 | `go build ./...` | 确保代码编译通过 | +| 静态分析 | `go vet ./...` | 检测常见代码问题 | +| 测试 | `go test -race ./...` | 运行全部测试,含竞态检测 | +| 格式化 | `gofmt -s -l .` | 确保代码格式符合 Go 标准 | + +## 已知问题 + +1. **Windows keyring**: Windows Credential Manager 可能需要额外配置 +2. **Linux keyring**: 需要 dbus 服务支持,无桌面环境时可能不可用 +3. **browse 命令**: Linux 环境需要安装 xdg-utils 包 diff --git a/doc/commands/wiki.md b/doc/commands/wiki.md new file mode 100644 index 0000000..00d52bd --- /dev/null +++ b/doc/commands/wiki.md @@ -0,0 +1,49 @@ +# wiki — Wiki 管理命令 + +> 关联 Issue: #13 | PR: #10 + +## 概述 + +wiki 模块提供 GitLink 仓库 Wiki 页面的管理命令,支持列出、查看、创建、更新和删除 Wiki 页面。 + +## 命令列表 + +### wiki +pages +- **用途**: 列出仓库所有 Wiki 页面 +- **API**: GET /api/wiki/wikiPages +- **参数**: 无(自动从 git remote 推断 owner/repo) +- **示例**: `gitlink-cli wiki +pages` + +### wiki +get +- **用途**: 获取指定 Wiki 页面内容 +- **API**: GET /api/wiki/getWiki?id=\ +- **参数**: --id, -i (必填) Wiki 页面 ID +- **示例**: `gitlink-cli wiki +get --id 42` + +### wiki +create +- **用途**: 创建新的 Wiki 页面 +- **API**: POST /api/wiki/createWiki +- **参数**: + - --title, -t (必填) 页面标题 + - --content, -c (必填) 页面内容(Markdown) + - --project (可选) 项目 ID +- **示例**: `gitlink-cli wiki +create --title "Getting Started" --content "# Welcome"` + +### wiki +update +- **用途**: 更新已有 Wiki 页面 +- **API**: PUT /api/wiki/updateWiki +- **参数**: + - --id, -i (必填) Wiki 页面 ID + - --title, -t (可选) 新标题 + - --content, -c (可选) 新内容(Markdown) +- **示例**: `gitlink-cli wiki +update --id 42 --title "Updated Title"` + +### wiki +delete +- **用途**: 删除 Wiki 页面 +- **API**: POST /api/wiki/deleteWiki +- **参数**: --id, -i (必填) Wiki 页面 ID +- **示例**: `gitlink-cli wiki +delete --id 42` + +## 向后兼容性 + +无破坏性变更。所有命令通过 wiki 域组 + 前缀添加。 diff --git a/internal/client/client.go b/internal/client/client.go index aa1a502..39b0bbc 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -41,34 +41,19 @@ func New() (*Client, error) { }, nil } -// Do makes an API call with automatic .json suffix appended. func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { path = normalizeAPIPath(c.BaseURL, path) - return c.do(method, path, body, query, true, "json") -} -// DoRaw makes an API call without appending .json suffix. -func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { - return c.do(method, path, body, query, false, "json") -} - -// DoForm makes an API call with form-encoded body (no .json suffix). -// Used for Wiki and other endpoints that expect application/x-www-form-urlencoded. -func (c *Client) DoForm(method, path string, body url.Values, query url.Values) (*output.Envelope, error) { - return c.do(method, path, body, query, false, "form") -} - -func (c *Client) do(method, path string, body interface{}, query url.Values, appendJSON bool, encoding string) (*output.Envelope, error) { - if appendJSON { - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if shouldAppendJSONSuffix(basePath) { - path = basePath + ".json" + queryStr - } - } else if shouldAppendJSONSuffix(path) { - path += ".json" + // Append .json suffix if not already present (GitLink API convention) + // Handle paths that may already contain query strings (e.g., /path?key=val) + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if shouldAppendJSONSuffix(basePath) { + path = basePath + ".json" + queryStr } + } else if shouldAppendJSONSuffix(path) { + path += ".json" } fullURL := c.BaseURL + path if len(query) > 0 { @@ -79,169 +64,7 @@ func (c *Client) do(method, path string, body interface{}, query url.Values, app fullURL += sep + query.Encode() } - var bodyData []byte - var bodyReader io.Reader - var contentType string - if body != nil { - if encoding == "form" { - formValues, ok := body.(url.Values) - if !ok { - return nil, fmt.Errorf("DoForm requires url.Values body") - } - bodyData = []byte(formValues.Encode()) - contentType = "application/x-www-form-urlencoded" - } else { - var err error - bodyData, err = json.Marshal(body) - if err != nil { - return nil, err - } - contentType = "application/json" - } - bodyReader = bytes.NewReader(bodyData) - } - - req, err := http.NewRequest(method, fullURL, bodyReader) - if err != nil { - return nil, err - } - - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - - if c.Debug { - fmt.Printf("→ %s %s\n", method, fullURL) - if bodyData != nil { - fmt.Printf(" body: %s\n", string(bodyData)) - } - } - - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - respData, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if c.Debug { - fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) - } - - if resp.StatusCode >= 400 { - return nil, &APIError{ - StatusCode: resp.StatusCode, - Code: resp.StatusCode, - Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), - } - } - - var raw map[string]interface{} - if err := json.Unmarshal(respData, &raw); err != nil { - return output.SuccessEnvelope(string(respData), nil), nil - } - - // Check GitLink error-in-body pattern - // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} - var bodyCode float64 - var bodyMsg string - if status, ok := raw["status"]; ok { - switch v := status.(type) { - case float64: - bodyCode = v - case int: - bodyCode = float64(v) - } - bodyMsg, _ = raw["message"].(string) - } else if code, ok := raw["code"]; ok { - switch v := code.(type) { - case float64: - bodyCode = v - case int: - bodyCode = float64(v) - } - bodyMsg, _ = raw["msg"].(string) - if bodyMsg == "" { - bodyMsg, _ = raw["message"].(string) - } - } - if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { - suggestion := suggestFix(int(bodyCode)) - return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ - StatusCode: int(bodyCode), - Code: int(bodyCode), - Message: bodyMsg, - } - } - - if dataStr, ok := raw["data"].(string); ok { - var parsedData interface{} - if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil { - raw["data"] = json.RawMessage(dataStr) - } - } - - var meta *output.Meta - if tc, ok := raw["total_count"]; ok { - meta = &output.Meta{} - if v, ok := tc.(float64); ok { - meta.TotalCount = int(v) - } - if v, ok := raw["page"].(float64); ok { - meta.Page = int(v) - } - if v, ok := raw["limit"].(float64); ok { - meta.Limit = int(v) - } - } - - return output.SuccessEnvelope(raw, meta), nil -} - -func shouldAppendJSONSuffix(path string) bool { - if strings.HasSuffix(path, ".json") { - return false - } - parts := strings.Split(strings.Trim(path, "/"), "/") - for i, part := range parts { - if part == "raw" && i >= 2 && i+2 < len(parts) { - return false - } - } - // Wiki open API endpoints do not use .json suffix - if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" { - return false - } - return true -} - -func normalizeAPIPath(baseURL, path string) string { - if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { - switch { - case path == "/api": - return "" - case strings.HasPrefix(path, "/api/"): - return strings.TrimPrefix(path, "/api") - } - } - return path -} - -// DoRaw makes an API call without appending .json to the path. -func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { - fullURL := c.BaseURL + path - if query != nil && len(query) > 0 { - sep := "?" - if strings.Contains(fullURL, "?") { - sep = "&" - } - fullURL += sep + query.Encode() - } - + // Replace path params var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) @@ -275,6 +98,7 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) } + // Check HTTP-level errors if resp.StatusCode >= 400 { return nil, &APIError{ StatusCode: resp.StatusCode, @@ -283,11 +107,25 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) } } + // Detect HTML responses (GitLink returns login pages when auth is missing) + if detectHTMLResponse(respData) { + msg := "服务器返回了 HTML 页面而非 JSON 数据" + suggestion := suggestHTMLFix() + return output.ErrorEnvelope(resp.StatusCode, msg, suggestion), + &APIError{ + StatusCode: resp.StatusCode, + Code: "HTML_RESPONSE", + Message: msg + "\n" + suggestion, + } + } + + // Parse JSON var raw map[string]interface{} if err := json.Unmarshal(respData, &raw); err != nil { return output.SuccessEnvelope(string(respData), nil), nil } + // Check GitLink error-in-body pattern if status, ok := raw["status"]; ok { var statusCode float64 switch v := status.(type) { @@ -307,6 +145,7 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) } } + // Auto-parse JSON string data (GitLink API quirk: some endpoints return data as JSON string) if dataStr, ok := raw["data"].(string); ok { var parsedData interface{} if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil { @@ -314,6 +153,7 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) } } + // Build meta from pagination info var meta *output.Meta if tc, ok := raw["total_count"]; ok { meta = &output.Meta{} @@ -331,6 +171,31 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) return output.SuccessEnvelope(raw, meta), nil } +func shouldAppendJSONSuffix(path string) bool { + if strings.HasSuffix(path, ".json") { + return false + } + parts := strings.Split(strings.Trim(path, "/"), "/") + for i, part := range parts { + if part == "raw" && i >= 2 && i+2 < len(parts) { + return false + } + } + return true +} + +func normalizeAPIPath(baseURL, path string) string { + if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { + switch { + case path == "/api": + return "" + case strings.HasPrefix(path, "/api/"): + return strings.TrimPrefix(path, "/api") + } + } + return path +} + func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { return c.Do("GET", path, nil, query) } @@ -347,6 +212,41 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } +// detectHTMLResponse detects whether the response body is an HTML page instead of JSON. +// It first strips any XML declaration () before checking for HTML prefixes. +func detectHTMLResponse(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + // Skip leading XML declaration (e.g., ) + if bytes.HasPrefix(trimmed, []byte("")); idx != -1 { + trimmed = bytes.TrimSpace(trimmed[idx+2:]) + } + } + if len(trimmed) == 0 { + return false + } + // Check for HTML document prefixes + prefixes := []string{"...`, true}, + {"html 小写开头", `...`, true}, + {"HTML 大写开头", `...`, true}, + {"doctype 小写开头", ``, true}, + {"空响应体", "", false}, + {"纯文本", `just some text`, false}, + {"空白后 HTML", ` `, true}, + {"JSON 数组", `[1,2,3]`, false}, + {"HTML 片段(无前缀)", `content`, false}, + {"XML 声明后跟 HTML", ``, true}, } - for _, p := range paths { - if shouldAppendJSONSuffix(p) { - t.Errorf("wiki/open path %q should not get .json suffix", p) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := detectHTMLResponse([]byte(tt.body)); got != tt.wantHTML { + t.Errorf("detectHTMLResponse(%q) = %v, want %v", tt.body, got, tt.wantHTML) + } + }) + } +} + +func TestClientDoHTMLResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`Sign inPlease log in`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + env, err := c.Do("GET", "/api/test", nil, nil) + if err == nil { + t.Fatal("expected error for HTML response") + } + if env == nil { + t.Fatal("expected envelope for HTML response") + } + if env.OK { + t.Fatal("expected OK=false for HTML response") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.Code != "HTML_RESPONSE" { + t.Fatalf("Code = %v, want HTML_RESPONSE", apiErr.Code) + } +} + +func TestSuggestHTMLFix(t *testing.T) { + msg := suggestHTMLFix() + if msg == "" { + t.Fatal("suggestHTMLFix should return a non-empty message") + } + if !strings.Contains(msg, "gitlink-cli auth login") { + t.Fatal("suggestHTMLFix should mention auth login") } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..8bac24a --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,40 @@ +# GitLink CLI 一键安装脚本 (Windows PowerShell) +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host " GitLink CLI 安装脚本 (Windows)" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +$binary = "gitlink-cli-windows-amd64.exe" +$url = "https://gitlink.org.cn/Gitlink/gitlink-cli/releases/download/latest/$binary" +$installDir = "$env:LOCALAPPDATA\gitlink-cli" +$dest = "$installDir\gitlink-cli.exe" + +Write-Host "下载地址: $url" + +# 创建安装目录 +if (-not (Test-Path $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null +} + +# 下载 +try { + Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + Write-Host "下载完成" -ForegroundColor Green +} catch { + Write-Host "下载失败: $_" -ForegroundColor Red + exit 1 +} + +# 添加到 PATH(用户级别) +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$installDir*") { + [Environment]::SetEnvironmentVariable( + "Path", + "$installDir;$userPath", + "User" + ) + Write-Host "已添加到用户 PATH" -ForegroundColor Green +} + +Write-Host "" +Write-Host "安装完成!" -ForegroundColor Green +Write-Host "请重新打开终端,运行 gitlink-cli --help 验证安装" -ForegroundColor Yellow diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..d1c19f4 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# GitLink CLI 一键安装脚本 (Linux / macOS) +set -e + +echo "=========================================" +echo " GitLink CLI 安装脚本" +echo "=========================================" + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +# 转换架构名称 +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + armv7l) ARCH="armv7" ;; + *) echo "不支持的架构: $ARCH"; exit 1 ;; +esac + +echo "检测到系统: ${OS} ${ARCH}" + +BINARY="gitlink-cli-${OS}-${ARCH}" +URL="https://gitlink.org.cn/Gitlink/gitlink-cli/releases/download/latest/${BINARY}" + +echo "下载地址: $URL" + +# 下载 +if command -v curl &> /dev/null; then + curl -fsSL "$URL" -o /tmp/gitlink-cli +elif command -v wget &> /dev/null; then + wget -q "$URL" -O /tmp/gitlink-cli +else + echo "错误: 需要 curl 或 wget" + exit 1 +fi + +# 安装 +chmod +x /tmp/gitlink-cli + +if [ "$(id -u)" -eq 0 ]; then + mv /tmp/gitlink-cli /usr/local/bin/gitlink-cli +else + echo "需要 sudo 权限安装到 /usr/local/bin/" + sudo mv /tmp/gitlink-cli /usr/local/bin/gitlink-cli +fi + +echo "" +echo "✅ 安装完成!" +echo " 运行 gitlink-cli --help 验证安装" diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index b291290..4886d12 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -89,6 +89,48 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "activate", + Description: "为仓库激活 CI/CD 功能", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/activate", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "deactivate", + Description: "停用仓库的 CI/CD 功能", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/deactivate", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "authorize", + Description: "检查仓库的 CI/CD 授权状态", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/ci_authorize", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, newCIToggleShortcut("enable", "Enable CI for a repository"), newCIToggleShortcut("disable", "Disable CI for a repository"), diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 79d240e..2e466e6 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -458,6 +458,76 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "journals", + Description: "查看 Issue 的活动日志(评论、状态变更等)", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue 编号(网页 URL 中的数字)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := ctx.RequireArg("number") + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "series-update", + Description: "批量更新多个 Issue 的状态(一键关闭/重开多个 Issue)", + Flags: []common.Flag{ + {Name: "ids", Usage: "Issue ID 列表(逗号分隔,如 1,2,3)", Required: true}, + {Name: "status", Short: "s", Usage: "目标状态: open / closed", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + idsStr, err := ctx.RequireArg("ids") + if err != nil { + return err + } + status, err := ctx.RequireArg("status") + if err != nil { + return err + } + + // 解析逗号分隔的 ID 列表 + idParts := strings.Split(idsStr, ",") + ids := make([]int, 0, len(idParts)) + for _, p := range idParts { + id, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return fmt.Errorf("无效的 Issue ID: %s", p) + } + ids = append(ids, id) + } + + // 转换状态为数字 + statusID, err := normalizeIssueStatus(status) + if err != nil { + return err + } + + body := map[string]interface{}{ + "ids": ids, + "status_id": statusID, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/issues/series_update", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go index 35e923c..485c73d 100644 --- a/shortcuts/notification/notification.go +++ b/shortcuts/notification/notification.go @@ -4,52 +4,31 @@ import ( "fmt" "net/url" - "strconv" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns notification management shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: "List notifications", + Description: "列出通知", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "显示所有通知(含已读)", Bool: true, Default: "false"}, + {Name: "participating", Usage: "仅显示参与的通知", Bool: true, Default: "false"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - login, err := resolveLogin(ctx) - if err != nil { - return err - } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/messages", login), q) - if err != nil { - return err + if ctx.Arg("all") == "true" { + q.Set("all", "true") } - return ctx.Output(env) - }, - }, - { - Name: "view", - Description: "View notification details", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Notification ID", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - login, err := resolveLogin(ctx) - if err != nil { - return err + if ctx.Arg("participating") == "true" { + q.Set("participating", "true") } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/messages/%s", login, id), nil) + env, err := ctx.CallAPIWithQuery("GET", "/notifications", q) if err != nil { return err } @@ -58,28 +37,16 @@ func Shortcuts() []*common.Shortcut { }, { Name: "read", - Description: "Mark a notification as read", + Description: "标记单条通知为已读", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Notification ID", Required: true}, + {Name: "id", Short: "i", Usage: "通知 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - login, err := resolveLogin(ctx) - if err != nil { - return err - } id, err := ctx.RequireArg("id") if err != nil { return err } - idInt, err := strconv.Atoi(id) - if err != nil { - return fmt.Errorf("invalid id: %s", id) - } - body := map[string]interface{}{ - "type": "notification", - "ids": []int{idInt}, - } - env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages/read", login), body) + env, err := ctx.CallAPI("PUT", fmt.Sprintf("/notifications/%s", id), nil) if err != nil { return err } @@ -87,29 +54,10 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "delete", - Description: "Delete a notification", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Notification ID", Required: true}, - }, + Name: "read-all", + Description: "标记所有通知为已读", Run: func(ctx *common.RuntimeContext) error { - login, err := resolveLogin(ctx) - if err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - idInt, err := strconv.Atoi(id) - if err != nil { - return fmt.Errorf("invalid id: %s", id) - } - body := map[string]interface{}{ - "type": "notification", - "ids": []int{idInt}, - } - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/users/%s/messages", login), body) + env, err := ctx.CallAPI("PUT", "/notifications", nil) if err != nil { return err } @@ -118,11 +66,3 @@ func Shortcuts() []*common.Shortcut { }, } } - -// resolveLogin returns the user login from the runtime context. -func resolveLogin(ctx *common.RuntimeContext) (string, error) { - if ctx.Owner == "" { - return "", fmt.Errorf("provide --owner (your login) or set it via config") - } - return ctx.Owner, nil -} diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4733853..1f9a247 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -132,6 +132,72 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "teams", + Description: "列出组织下的所有团队", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/teams", id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create-team", + Description: "在组织下创建新团队", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + {Name: "name", Short: "n", Usage: "团队名称", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + body := map[string]interface{}{"name": name} + env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "remove-user", + Description: "从组织中移除成员", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + {Name: "user", Short: "u", Usage: "要移除的用户 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + orgID, err := ctx.RequireArg("id") + if err != nil { + return err + } + userID, err := ctx.RequireArg("user") + if err != nil { + return err + } + path := fmt.Sprintf("/organizations/%s/organization_users/%s", orgID, userID) + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, { Name: "teams", diff --git a/shortcuts/pm/pm.go b/shortcuts/pm/pm.go index 0f42e1d..991a4e4 100644 --- a/shortcuts/pm/pm.go +++ b/shortcuts/pm/pm.go @@ -1,122 +1,137 @@ package pm import ( - "fmt" "net/url" - "strconv" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// Shortcuts returns project management shortcuts for GitLink. +// +// The pm domain provides commands for viewing dashboards, sprints, +// weekly issues, tags, pipelines, and action runs associated with +// a project. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { - Name: "boards", - Description: "List kanban boards", + Name: "dashboards", + Description: "查看项目仪表盘数据", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/dashboards") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/dashboards", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { Name: "sprints", - Description: "List sprint issues", + Description: "查看 Sprint 任务列表", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/sprint_issues") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/sprint_issues", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { Name: "weekly", - Description: "List weekly reports", + Description: "查看周报任务", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/weekly_issues") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/weekly_issues", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { Name: "tags", - Description: "List PM issue tags", + Description: "查看项目 Issue 标签", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/issue_tags") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/issue_tags", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { Name: "pipelines", - Description: "List PM pipelines", + Description: "查看项目 CI/CD 流水线列表", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/pipelines") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/pipelines", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { - Name: "actions", - Description: "List action run records", + Name: "runs", + Description: "查看项目 Action 运行记录", Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "project", Usage: "项目 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - return listPM(ctx, "/pm/action_runs") + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/action_runs", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, } } - -func listPM(ctx *common.RuntimeContext, endpoint string) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - projectID, err := fetchProjectID(ctx) - if err != nil { - return err - } - q := url.Values{} - q.Set("project_id", strconv.Itoa(projectID)) - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIRawWithQuery("GET", endpoint, q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func fetchProjectID(ctx *common.RuntimeContext) (int, error) { - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return 0, fmt.Errorf("获取项目信息失败: %w", err) - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("无法解析项目信息") - } - if idFloat, ok := data["repo_id"].(float64); ok { - return int(idFloat), nil - } - if idFloat, ok := data["project_id"].(float64); ok { - return int(idFloat), nil - } - if idFloat, ok := data["id"].(float64); ok { - return int(idFloat), nil - } - return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在") -} diff --git a/shortcuts/pm/pm_test.go b/shortcuts/pm/pm_test.go index 18b498d..781d09f 100644 --- a/shortcuts/pm/pm_test.go +++ b/shortcuts/pm/pm_test.go @@ -1,200 +1,220 @@ package pm import ( + "encoding/json" "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestFetchProjectID(t *testing.T) { - cases := []struct { - name string - response map[string]interface{} - wantID int +func TestPmDashboards(t *testing.T) { + tests := []struct { + name string + mockStatus int + mockBody string + wantErr bool + errContains string }{ - {"repo_id", map[string]interface{}{"repo_id": float64(100)}, 100}, - {"project_id", map[string]interface{}{"project_id": float64(200)}, 200}, - {"id", map[string]interface{}{"id": float64(300)}, 300}, + {"正常返回", 200, `{"dashboards": []}`, false, ""}, + {"API 404", 404, `{"error": "not found"}`, true, "404"}, + {"返回 HTML", 200, `Login`, true, "HTML"}, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resp := tc.response - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" && r.URL.Path == "/owner/repo.json" { - common.WriteJSON(t, w, resp) - return + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) } - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - }) + if !strings.Contains(r.URL.Path, "/pm/dashboards") { + t.Errorf("expected path containing /pm/dashboards, got %s", r.URL.Path) + } + if got := r.URL.Query().Get("project_id"); got != "123" { + t.Errorf("expected project_id=123, got %s", got) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - id, err := fetchProjectID(ctx) - if err != nil { - t.Fatalf("fetchProjectID failed: %v", err) + shortcut := findPmShortcut(t, "dashboards") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "123"}, } - if id != tc.wantID { - t.Fatalf("got %d, want %d", id, tc.wantID) + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("错误应包含 %q: %s", tt.errContains, err.Error()) + } } }) } } -func TestFetchProjectIDNotFound(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - common.WriteJSON(t, w, map[string]interface{}{"name": "repo"}) - }) +func TestPmSprints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/pm/sprint_issues") { + t.Errorf("expected path containing /pm/sprint_issues, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"sprint_issues": []}`)) + })) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - _, err := fetchProjectID(ctx) + shortcut := findPmShortcut(t, "sprints") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "456"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("sprints shortcut failed: %v", err) + } +} + +func TestPmWeekly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/weekly_issues") { + t.Errorf("expected path containing /pm/weekly_issues, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"weekly_issues": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "weekly") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "789"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("weekly shortcut failed: %v", err) + } +} + +func TestPmTags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/issue_tags") { + t.Errorf("expected path containing /pm/issue_tags, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"issue_tags": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "tags") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "100"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("tags shortcut failed: %v", err) + } +} + +func TestPmPipelines(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/pipelines") { + t.Errorf("expected path containing /pm/pipelines, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"pipelines": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "pipelines") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "200"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("pipelines shortcut failed: %v", err) + } +} + +func TestPmRuns(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/action_runs") { + t.Errorf("expected path containing /pm/action_runs, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"action_runs": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "runs") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "300"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("runs shortcut failed: %v", err) + } +} + +func TestPmMissingProject(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --project is missing") + })) + defer server.Close() + + shortcut := findPmShortcut(t, "dashboards") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + err := shortcut.Run(ctx) if err == nil { - t.Fatal("expected error for missing project ID") + t.Fatal("expected error when --project is missing") } } -func TestPMBoards(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/dashboards": - common.WriteJSON(t, w, map[string]interface{}{ - "boards": []interface{}{ - map[string]interface{}{"id": 1, "name": "Sprint 1"}, - map[string]interface{}{"id": 2, "name": "Sprint 2"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) +func findPmShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut } - }) - defer server.Close() + } + t.Fatalf("shortcut %q not found", name) + return nil +} - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "boards", ctx) - if err != nil { - t.Fatalf("boards failed: %v", err) +func assertPmRequest(t *testing.T, r *http.Request, method, pathPrefix string) { + t.Helper() + if r.Method != method { + t.Fatalf("got method %s, want %s", r.Method, method) + } + if !strings.HasPrefix(r.URL.Path, pathPrefix) { + t.Fatalf("got path %s, want prefix %s", r.URL.Path, pathPrefix) } } -func TestPMSprints(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/sprint_issues": - common.WriteJSON(t, w, map[string]interface{}{ - "issues": []interface{}{ - map[string]interface{}{"id": 10, "subject": "Task A"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "sprints", ctx) - if err != nil { - t.Fatalf("sprints failed: %v", err) - } -} - -func TestPMWeekly(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/weekly_issues": - common.WriteJSON(t, w, map[string]interface{}{ - "reports": []interface{}{ - map[string]interface{}{"id": 1, "title": "Week 21"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "weekly", ctx) - if err != nil { - t.Fatalf("weekly failed: %v", err) - } -} - -func TestPMTags(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/issue_tags": - common.WriteJSON(t, w, map[string]interface{}{ - "tags": []interface{}{ - map[string]interface{}{"id": 1, "name": "bug"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "tags", ctx) - if err != nil { - t.Fatalf("tags failed: %v", err) - } -} - -func TestPMPipelines(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/pipelines": - common.WriteJSON(t, w, map[string]interface{}{ - "pipelines": []interface{}{ - map[string]interface{}{"id": 1, "name": "CI"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "pipelines", ctx) - if err != nil { - t.Fatalf("pipelines failed: %v", err) - } -} - -func TestPMActions(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) - case r.Method == "GET" && r.URL.Path == "/pm/action_runs": - common.WriteJSON(t, w, map[string]interface{}{ - "runs": []interface{}{ - map[string]interface{}{"id": 1, "status": "success"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "actions", ctx) - if err != nil { - t.Fatalf("actions failed: %v", err) +func decodePmJSON(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 } diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index a4129e9..330c4f9 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -10,25 +10,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func v1RepoPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) -} - -func normalizePullRequestListState(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "open", "opened": - return "0" - case "merged": - return "1" - case "closed": - return "2" - case "all", "": - return "" - default: - return state - } -} - func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ @@ -37,17 +18,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.pr.list.short"), Flags: []common.Flag{ {Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"}, - {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")}, - {Name: "priority-id", Usage: tr.T("flag.pr.priority_id")}, - {Name: "tag-id", Usage: tr.T("flag.pr.tag_id")}, - {Name: "milestone-id", Usage: tr.T("flag.pr.milestone_id")}, - {Name: "reviewer-id", Usage: tr.T("flag.pr.reviewer_id")}, - {Name: "assignee-id", Usage: tr.T("flag.pr.assignee_id")}, - {Name: "sort-by", Usage: tr.T("flag.sort_by")}, - {Name: "sort-direction", Usage: tr.T("flag.sort_direction")}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -56,41 +28,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if s := normalizePullRequestListState(ctx.Arg("state")); s != "" { - q.Set("status", s) + if s := ctx.Arg("state"); s != "" { + q.Set("state", s) } - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - if priorityID := ctx.Arg("priority-id"); priorityID != "" { - q.Set("priority_id", priorityID) - } - if tagID := ctx.Arg("tag-id"); tagID != "" { - q.Set("issue_tag_id", tagID) - } - if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { - q.Set("version_id", milestoneID) - } - if reviewerID := ctx.Arg("reviewer-id"); reviewerID != "" { - q.Set("reviewer_id", reviewerID) - } - if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { - q.Set("assign_user_id", assigneeID) - } - if sortBy := ctx.Arg("sort-by"); sortBy != "" { - q.Set("sort_by", sortBy) - } - if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" { - q.Set("sort_direction", sortDirection) - } - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(v1RepoPath(ctx)+"/pulls", q, "pulls") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("pulls", items)) - } - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", q) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) if err != nil { return err } @@ -179,8 +120,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "refuse", - Description: "Refuse and close a pull request", + Name: "close", + Description: tr.T("cmd.pr.close.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, @@ -441,18 +382,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "commits", - Description: tr.T("cmd.pr.commits.short"), + Description: "List commits in a pull request", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "id", Short: "i", Usage: "PR number", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - // The pulls commits endpoint ignores page/limit and always - // returns the full list, so no pagination flags are exposed. - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/commits", nil) if err != nil { return err } @@ -460,19 +402,44 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "comments", - Description: tr.T("cmd.pr.comments.short"), + Name: "branches", + Description: "List branches for pull request creation", + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "check-merge", + Description: "Check if two branches can be merged", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "head", Usage: "Source branch", Required: true}, + {Name: "base", Usage: "Target branch", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - // The pulls journals endpoint ignores page/limit and always - // returns the full list, so no pagination flags are exposed. - env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil) + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + payload := map[string]interface{}{ + "head": head, + "base": base, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) if err != nil { return err } diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index b50a505..f251f70 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -481,6 +481,120 @@ func TestPRDiffHTTPError(t *testing.T) { } } +// --- commits --- + +func TestPRCommits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/pulls/42/commits.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, []interface{}{ + map[string]interface{}{"sha": "abc1234", "message": "fix: bug"}, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("commits failed: %v", err) + } +} + +func TestPRCommitsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- branches --- + +func TestPRBranches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/pulls/get_branches.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, []interface{}{ + map[string]interface{}{"name": "master"}, + map[string]interface{}{"name": "develop"}, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "branches", map[string]string{}) + if err != nil { + t.Fatalf("branches failed: %v", err) + } +} + +func TestPRBranchesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "branches", map[string]string{}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- check-merge --- + +func TestPRCheckMerge(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/pulls/check_can_merge.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"can_merge": true}) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feature/x", + "base": "master", + }) + if err != nil { + t.Fatalf("check-merge failed: %v", err) + } + assertEqual(t, payload["head"], "feature/x") + assertEqual(t, payload["base"], "master") +} + +func TestPRCheckMergeHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feature/x", + "base": "master", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) diff --git a/shortcuts/register.go b/shortcuts/register.go index 50d7a7a..85ac32d 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,26 +6,21 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" - "github.com/gitlink-org/gitlink-cli/shortcuts/commit" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" - "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" - "github.com/gitlink-org/gitlink-cli/shortcuts/feishu" - "github.com/gitlink-org/gitlink-cli/shortcuts/health" - "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" + "github.com/gitlink-org/gitlink-cli/shortcuts/export" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" - "github.com/gitlink-org/gitlink-cli/shortcuts/license" "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" + "github.com/gitlink-org/gitlink-cli/shortcuts/notification" "github.com/gitlink-org/gitlink-cli/shortcuts/org" + "github.com/gitlink-org/gitlink-cli/shortcuts/pm" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" - "github.com/gitlink-org/gitlink-cli/shortcuts/profile" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" - "github.com/gitlink-org/gitlink-cli/shortcuts/tag" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -42,27 +37,22 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "repo": repo.Shortcuts(tr), "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), - "license": license.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), + "notification": notification.Shortcuts(), "pipeline": pipeline.Shortcuts(), + "pm": pm.Shortcuts(), "pr": pr.Shortcuts(tr), - "profile": profile.Shortcuts(tr), "release": release.Shortcuts(tr), "branch": branch.Shortcuts(tr), "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), - "tag": tag.Shortcuts(), - "commit": commit.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), - "dataset": dataset.Shortcuts(tr), - "feishu": feishu.Shortcuts(tr), + "export": export.Shortcuts(), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), - "health": health.Shortcuts(tr), - "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), } @@ -70,27 +60,22 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "repo": tr.T("cmd.repo.short"), "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", - "license": "License operations", "member": "Repository member operations", "milestone": "Milestone operations", + "notification": "Notification operations", "pipeline": "Pipeline operations", + "pm": "Project management operations", "pr": tr.T("cmd.pr.short"), - "profile": tr.T("cmd.profile.short"), "release": tr.T("cmd.release.short"), "branch": tr.T("cmd.branch.short"), "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), - "tag": "Git tag operations", - "commit": "Commit history operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", - "dataset": tr.T("cmd.dataset.short"), - "feishu": "Export GitLink workflow data to Feishu", + "export": "Data export to CSV/JSON", "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", - "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), + "wiki": "Wiki page operations", "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 360dc69..5a46173 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -375,6 +375,116 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "languages", + Description: "Show language breakdown of a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "contributors", + Description: "List contributors of a repository", + Flags: []common.Flag{ + {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")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/contributors", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List files in a repository directory", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Short: "p", Usage: "Directory path (default: repository root)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if p := ctx.Arg("path"); p != "" { + q.Set("filepath", p) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tags", + Description: "List tags of a repository", + Flags: []common.Flag{ + {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")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "commits", + Description: "List commits of a repository", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA"}, + {Name: "path", Short: "p", Usage: "Filter commits 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 + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if sha := ctx.Arg("sha"); sha != "" { + q.Set("sha", sha) + } + if p := ctx.Arg("path"); p != "" { + q.Set("path", p) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/commits", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index f4de653..2008664 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -2,10 +2,8 @@ package repo import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" - "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -16,18 +14,12 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() s := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, + 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 s.Run(ctx) } @@ -42,29 +34,9 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { - t.Helper() +func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - t.Fatalf("write response: %v", err) - } -} - -func writeText(t *testing.T, w http.ResponseWriter, code int, text string) { - t.Helper() - w.WriteHeader(code) - if _, err := w.Write([]byte(text)); err != nil { - t.Fatalf("write response: %v", err) - } -} - -func decodeRepoJSON(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("decode request body: %v", err) - } - return payload + json.NewEncoder(w).Encode(v) } // --- list --- @@ -77,7 +49,7 @@ func TestRepoListDefault(t *testing.T) { if r.URL.Path != "/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(t, w, map[string]interface{}{ + writeJSON(w, map[string]interface{}{ "total_count": float64(2), "data": []interface{}{map[string]interface{}{"name": "repo1"}}, }) @@ -95,7 +67,7 @@ func TestRepoListForUser(t *testing.T) { if r.URL.Path != "/users/alice/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) + writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) })) defer server.Close() @@ -110,7 +82,7 @@ func TestRepoListWithCategory(t *testing.T) { if r.URL.Query().Get("category") != "mirror" { t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category")) } - writeJSON(t, w, map[string]interface{}{"data": []interface{}{}}) + writeJSON(w, map[string]interface{}{"data": []interface{}{}}) })) defer server.Close() @@ -120,14 +92,14 @@ func TestRepoListWithCategory(t *testing.T) { } } -// --- info/readme/insights --- +// --- info --- func TestRepoInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(t, w, map[string]interface{}{ + writeJSON(w, map[string]interface{}{ "name": "repo", "description": "test repo", }) @@ -140,712 +112,7 @@ func TestRepoInfo(t *testing.T) { } } -func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/readme.json") - assertEqual(t, r.URL.Query().Get("ref"), "main") - assertEqual(t, r.URL.Query().Get("filepath"), "docs") - writeJSON(t, w, map[string]interface{}{ - "type": "file", - "name": "README.md", - "content": "# docs\n", - }) - })) - defer server.Close() - - err := runShortcut(t, server, "readme", map[string]string{ - "ref": "main", - "path": "docs", - }) - if err != nil { - t.Fatalf("readme shortcut failed: %v", err) - } -} - -func TestRepoUnitsUsesProjectUnitsEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/project_units.json") - writeJSON(t, w, []string{"code", "issues", "pulls"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "units", nil); err != nil { - t.Fatalf("units shortcut failed: %v", err) - } -} - -func TestRepoSetUnitsSendsValidatedUnitTypes(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/project_units.json") - var body struct { - UnitTypes []string `json:"unit_types"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request: %v", err) - } - want := []string{"code", "issues", "pulls"} - if !reflect.DeepEqual(body.UnitTypes, want) { - t.Fatalf("unit_types = %#v, want %#v", body.UnitTypes, want) - } - writeJSON(t, w, map[string]interface{}{ - "status": 0, - "message": "success", - }) - })) - defer server.Close() - - err := runShortcut(t, server, "set-units", map[string]string{ - "units": " code,issues,pulls,issues ", - }) - if err != nil { - t.Fatalf("set-units shortcut failed: %v", err) - } -} - -func TestRepoSetUnitsRejectsInvalidUnitBeforeRequest(t *testing.T) { - called := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - })) - defer server.Close() - - err := runShortcut(t, server, "set-units", map[string]string{"units": "code,invalid"}) - if err == nil { - t.Fatalf("expected invalid unit error") - } - if called { - t.Fatalf("server was called for invalid unit") - } -} - -func TestParseRepoUnitsRejectsEmptyInput(t *testing.T) { - if _, err := parseRepoUnits(" , "); err == nil { - t.Fatalf("expected empty unit list error") - } -} - -func TestRepoTreeListsRootOnDefaultRef(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - if _, ok := r.URL.Query()["filepath"]; ok { - t.Fatalf("did not expect filepath query for repository root, got %q", r.URL.Query().Get("filepath")) - } - assertEqual(t, r.URL.Query().Get("ref"), "master") - writeJSON(t, w, map[string]interface{}{ - "entries": []map[string]interface{}{ - {"name": "README.md", "type": "file"}, - }, - }) - })) - defer server.Close() - - if err := runShortcut(t, server, "tree", nil); err != nil { - t.Fatalf("tree shortcut failed: %v", err) - } -} - -func TestRepoTreeUsesPathAndRef(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "cmd") - assertEqual(t, r.URL.Query().Get("ref"), "main") - writeJSON(t, w, map[string]interface{}{ - "entries": []map[string]interface{}{ - {"name": "main.go", "type": "file"}, - }, - }) - })) - defer server.Close() - - if err := runShortcut(t, server, "tree", map[string]string{"path": "cmd", "ref": "main"}); err != nil { - t.Fatalf("tree shortcut failed: %v", err) - } -} - -func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) { - tree := findShortcut(t, "tree") - if tree.Description == "" { - t.Fatal("tree shortcut description is empty") - } - - flags := map[string]common.Flag{} - for _, flag := range tree.Flags { - flags[flag.Name] = flag - } - - pathFlag, ok := flags["path"] - if !ok { - t.Fatal("tree shortcut missing path flag") - } - if pathFlag.Short != "p" || pathFlag.Usage == "" { - t.Fatalf("unexpected path flag: %+v", pathFlag) - } - - refFlag, ok := flags["ref"] - if !ok { - t.Fatal("tree shortcut missing ref flag") - } - if refFlag.Short != "r" || refFlag.Default != "master" || refFlag.Usage == "" { - t.Fatalf("unexpected ref flag: %+v", refFlag) - } -} - -func TestRepoRawUsesRefAndEscapedPath(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - assertEqual(t, r.URL.EscapedPath(), "/owner/repo/raw/release%2Fv1/docs/My%20File.md") - writeText(t, w, http.StatusOK, "# title\n") - })) - defer server.Close() - - err := runShortcut(t, server, "raw", map[string]string{ - "ref": "release/v1", - "path": "/docs/My File.md", - }) - if err != nil { - t.Fatalf("raw shortcut failed: %v", err) - } -} - -func TestRepoRawDefaultsRefToMaster(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/raw/master/LICENSE") - writeText(t, w, http.StatusOK, "MIT License\n") - })) - defer server.Close() - - if err := runShortcut(t, server, "raw", map[string]string{"path": "LICENSE"}); err != nil { - t.Fatalf("raw shortcut failed: %v", err) - } -} - -func TestRepoFileExistsFindsEntryByName(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "src/main.go") - assertEqual(t, r.URL.Query().Get("ref"), "main") - writeJSON(t, w, map[string]interface{}{ - "entries": []interface{}{ - map[string]interface{}{"name": "main.go", "type": "file"}, - }, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "file-exists", map[string]string{ - "path": "src/main.go", - "ref": "main", - }) - if err != nil { - t.Fatalf("file-exists shortcut failed: %v", err) - } -} - -func TestRepoFileExistsReportsMissing(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "file-exists", map[string]string{"path": "missing.txt"}); err != nil { - t.Fatalf("file-exists missing shortcut failed: %v", err) - } -} - -func TestRepoManifestGoReadsGoMod(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "go.mod") - assertEqual(t, r.URL.Query().Get("ref"), "master") - writeJSON(t, w, map[string]interface{}{ - "entries": []interface{}{map[string]interface{}{"name": "go.mod", "type": "file"}}, - }) - case 2: - assertRequest(t, r, "GET", "/owner/repo/raw/master/go.mod") - writeText(t, w, http.StatusOK, "module example.com/repo\n") - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - if err := runShortcut(t, server, "manifest", map[string]string{"kind": "go"}); err != nil { - t.Fatalf("manifest go shortcut failed: %v", err) - } - assertEqual(t, requests, 2) -} - -func TestRepoManifestPythonFallsBackToPyproject(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "requirements.txt") - writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}}) - case 2: - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "pyproject.toml") - writeJSON(t, w, map[string]interface{}{ - "entries": []interface{}{map[string]interface{}{"path": "pyproject.toml", "type": "file"}}, - }) - case 3: - assertRequest(t, r, "GET", "/owner/repo/raw/master/pyproject.toml") - writeText(t, w, http.StatusOK, "[project]\n") - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - if err := runShortcut(t, server, "manifest", map[string]string{"kind": "python"}); err != nil { - t.Fatalf("manifest python shortcut failed: %v", err) - } - assertEqual(t, requests, 3) -} - -func TestRepoManifestReportsNotFound(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "manifest", map[string]string{"kind": "java"}); err != nil { - t.Fatalf("manifest missing shortcut failed: %v", err) - } - assertEqual(t, requests, 3) -} - -func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/languages.json") - writeJSON(t, w, map[string]interface{}{"Go": "92.4%", "Shell": "7.6%"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "languages", nil); err != nil { - t.Fatalf("languages shortcut failed: %v", err) - } -} - -func TestRepoContributorsUsesContributorsEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/contributors.json") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "list": []interface{}{}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "contributors", nil); err != nil { - t.Fatalf("contributors shortcut failed: %v", err) - } -} - -func TestRepoContributorStatsBuildsQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/contributors/stat.json") - assertEqual(t, r.URL.Query().Get("ref"), "main") - assertEqual(t, r.URL.Query().Get("pass_year"), "2") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "contributors": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "contributor-stats", map[string]string{ - "ref": " main ", - "pass-year": "2", - }) - if err != nil { - t.Fatalf("contributor-stats shortcut failed: %v", err) - } -} - -func TestRepoCodeStatsUsesRefQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/code_stats.json") - assertEqual(t, r.URL.Query().Get("ref"), "release/v1") - writeJSON(t, w, map[string]interface{}{"author_count": 1, "commit_count": 3}) - })) - defer server.Close() - - if err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/v1"}); err != nil { - t.Fatalf("code-stats shortcut failed: %v", err) - } -} - -func TestRepoFilesBuildsSearchAndRefQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/files.json") - assertEqual(t, r.URL.Query().Get("search"), "README") - assertEqual(t, r.URL.Query().Get("ref"), "main") - writeJSON(t, w, []interface{}{map[string]interface{}{"path": "README.md"}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "files", map[string]string{"search": "README", "ref": "main"}); err != nil { - t.Fatalf("files shortcut failed: %v", err) - } -} - -func TestRepoCommitsBuildsRefPaginationQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/commits.json") - assertEqual(t, r.URL.Query().Get("sha"), "main") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "commits": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commits", map[string]string{"ref": "main", "page": "2", "limit": "50"}) - if err != nil { - t.Fatalf("commits shortcut failed: %v", err) - } -} - -func TestRepoCommitFilesUsesPaginationWhenNoFileFilter(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc123/files.json") - assertEqual(t, r.URL.Query().Get("page"), "3") - assertEqual(t, r.URL.Query().Get("limit"), "10") - if got := r.URL.Query().Get("filepath"); got != "" { - t.Fatalf("did not expect filepath query, got %q", got) - } - writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commit-files", map[string]string{"sha": "abc123", "page": "3", "limit": "10"}) - if err != nil { - t.Fatalf("commit-files shortcut failed: %v", err) - } -} - -func TestRepoCommitFilesUsesFileFilterWithoutPagination(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc123/files.json") - assertEqual(t, r.URL.Query().Get("filepath"), "src/main.go") - if got := r.URL.Query().Get("page"); got != "" { - t.Fatalf("did not expect page query with filepath filter, got %q", got) - } - writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commit-files", map[string]string{"sha": "abc123", "file": "src/main.go"}) - if err != nil { - t.Fatalf("commit-files shortcut failed: %v", err) - } -} - -func TestRepoCommitDiffUsesCommitDiffEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc123/diff.json") - writeJSON(t, w, map[string]interface{}{"diff": "@@ -1 +1 @@"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "commit-diff", map[string]string{"sha": "abc123"}); err != nil { - t.Fatalf("commit-diff shortcut failed: %v", err) - } -} - -func TestRepoTagsBuildsPaginationQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/tags.json") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "30") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "tags": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "tags", map[string]string{"page": "2", "limit": "30"}) - if err != nil { - t.Fatalf("tags shortcut failed: %v", err) - } -} - -func TestRepoTagsUsesSearchEndpointForNameFilter(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/tags.json") - assertEqual(t, r.URL.Query().Get("name"), "v1") - assertEqual(t, r.URL.Query().Get("only_name"), "true") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "tags": []interface{}{map[string]interface{}{"name": "v1.0.0"}}}) - })) - defer server.Close() - - err := runShortcut(t, server, "tags", map[string]string{"name": "v1", "only-name": "true"}) - if err != nil { - t.Fatalf("tags shortcut with name filter failed: %v", err) - } -} - -func TestRepoTagUsesTagDetailEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/tags/v1.0.0.json") - writeJSON(t, w, map[string]interface{}{"name": "v1.0.0"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "tag", map[string]string{"name": "v1.0.0"}); err != nil { - t.Fatalf("tag shortcut failed: %v", err) - } -} - -func TestRepoDeleteTagDryRunDoesNotCallAPI(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("delete-tag dry-run should not call remote API: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "delete-tag", map[string]string{"name": "v1.0.0", "dry-run": "true"}) - if err != nil { - t.Fatalf("delete-tag dry-run failed: %v", err) - } -} - -func TestRepoDeleteTagRequiresYesForRemoteDelete(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("delete-tag without --yes should not call remote API: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "delete-tag", map[string]string{"name": "v1.0.0"}) - if err == nil { - t.Fatal("expected error when delete-tag is missing --yes") - } -} - -func TestRepoDeleteTagWithYesCallsDeleteEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/v1/owner/repo/tags/v1.0.0.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - err := runShortcut(t, server, "delete-tag", map[string]string{"name": "v1.0.0", "yes": "true"}) - if err != nil { - t.Fatalf("delete-tag failed: %v", err) - } -} - -func TestRepoBatchCommitDryRunDoesNotCallAPI(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "batch-commit", map[string]string{ - "branch": "master", - "message": "docs: update", - "files": "update:README.md:# hello;delete:old.txt", - "new-branch": "docs/update-readme", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-commit dry-run failed: %v", err) - } -} - -func TestRepoBatchCommitRequiresYesForRemoteWrite(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("batch-commit without --yes should not call remote API: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "batch-commit", map[string]string{ - "branch": "master", - "message": "docs: update", - "files": "update:README.md:# hello", - }) - if err == nil { - t.Fatal("expected error when batch-commit is missing --yes") - } -} - -func TestRepoBatchCommitPostsPayloadWithYes(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/contents/batch.json") - payload = decodeRepoJSON(t, r) - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc123"}}) - })) - defer server.Close() - - err := runShortcut(t, server, "batch-commit", map[string]string{ - "branch": "master", - "message": "docs: update", - "files": "create:docs/new.md:# new;update:README.md:# hello;delete:old.txt", - "encoding": "text", - "author-name": "Alice", - "author-email": "alice@example.com", - "committer-name": "Bot", - "committer-email": "bot@example.com", - "yes": "true", - }) - if err != nil { - t.Fatalf("batch-commit failed: %v", err) - } - assertEqual(t, payload["branch"], "master") - assertEqual(t, payload["message"], "docs: update") - assertEqual(t, payload["author_name"], "Alice") - files, ok := payload["files"].([]interface{}) - if !ok || len(files) != 3 { - t.Fatalf("expected three file operations, got %#v", payload["files"]) - } - first, _ := files[0].(map[string]interface{}) - assertEqual(t, first["action_type"], "create") - assertEqual(t, first["file_path"], "docs/new.md") - assertEqual(t, first["content"], "# new") - assertEqual(t, first["encoding"], "text") -} - -func TestRepoBatchCommitRejectsInvalidFileSpec(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - t.Fatal("invalid file spec should fail before calling remote API") - })) - defer server.Close() - - err := runShortcut(t, server, "batch-commit", map[string]string{ - "branch": "master", - "message": "docs: update", - "files": "move:README.md:# hello", - "dry-run": "true", - }) - if err == nil { - t.Fatal("expected invalid file action error") - } -} - -func TestRepoWatchersBuildsTimeRangeQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/watchers.json") - assertEqual(t, r.URL.Query().Get("start_at"), "1714521600") - assertEqual(t, r.URL.Query().Get("end_at"), "1717200000") - writeJSON(t, w, map[string]interface{}{"count": 1, "users": []interface{}{}}) - })) - defer server.Close() - - err := runShortcut(t, server, "watchers", map[string]string{ - "start-at": "1714521600", - "end-at": "1717200000", - }) - if err != nil { - t.Fatalf("watchers shortcut failed: %v", err) - } -} - -func TestRepoStargazersUsesStargazersEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/stargazers.json") - writeJSON(t, w, map[string]interface{}{"count": 0, "users": []interface{}{}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "stargazers", nil); err != nil { - t.Fatalf("stargazers shortcut failed: %v", err) - } -} - -func TestRepoFollowResolvesProjectID(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"id": float64(123)}) - case 2: - assertRequest(t, r, "POST", "/watchers/follow.json") - assertEqual(t, r.URL.Query().Get("target_type"), "project") - assertEqual(t, r.URL.Query().Get("id"), "123") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true}) - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - if err := runShortcut(t, server, "follow", nil); err != nil { - t.Fatalf("follow shortcut failed: %v", err) - } - assertEqual(t, requests, 2) -} - -func TestRepoUnfollowUsesExplicitProjectID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/watchers/unfollow.json") - assertEqual(t, r.URL.Query().Get("target_type"), "project") - assertEqual(t, r.URL.Query().Get("id"), "456") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": false}) - })) - defer server.Close() - - if err := runShortcut(t, server, "unfollow", map[string]string{"project-id": "456"}); err != nil { - t.Fatalf("unfollow shortcut failed: %v", err) - } -} - -func TestRepoLikeUsesExplicitProjectID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/projects/456/praise_tread/like.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "like", map[string]string{"project-id": "456"}); err != nil { - t.Fatalf("like shortcut failed: %v", err) - } -} - -func TestRepoUnlikeResolvesStringProjectID(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"project_id": "789"}) - case 2: - assertRequest(t, r, "DELETE", "/projects/789/praise_tread/unlike.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - if err := runShortcut(t, server, "unlike", nil); err != nil { - t.Fatalf("unlike shortcut failed: %v", err) - } - assertEqual(t, requests, 2) -} - -func TestRepoInteractionDryRunDoesNotCallAPIWithExplicitProjectID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run with explicit project-id should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "like", map[string]string{ - "project-id": "456", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("dry-run shortcut failed: %v", err) - } -} - -// --- fork/delete/create --- +// --- fork --- func TestRepoFork(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -855,7 +122,7 @@ func TestRepoFork(t *testing.T) { if r.URL.Path != "/owner/repo/forks.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(t, w, map[string]interface{}{"message": "forked"}) + writeJSON(w, map[string]interface{}{"message": "forked"}) })) defer server.Close() @@ -865,6 +132,8 @@ func TestRepoFork(t *testing.T) { } } +// --- delete --- + func TestRepoDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { @@ -873,7 +142,7 @@ func TestRepoDelete(t *testing.T) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(t, w, map[string]interface{}{"message": "deleted"}) + writeJSON(w, map[string]interface{}{"message": "deleted"}) })) defer server.Close() @@ -883,139 +152,18 @@ func TestRepoDelete(t *testing.T) { } } -func TestRepoTransferOrgs(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/applied_transfer_projects/organizations.json") - writeJSON(t, w, map[string]interface{}{ - "total_count": float64(1), - "organizations": []interface{}{map[string]interface{}{"name": "target-org"}}, - }) - })) - defer server.Close() - - if err := runShortcut(t, server, "transfer-orgs", nil); err != nil { - t.Fatalf("transfer-orgs failed: %v", err) - } -} - -func TestRepoTransfer(t *testing.T) { - var body map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"status": "common"}) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{ - "target-owner": " target-org ", - "yes": "true", - }) - if err != nil { - t.Fatalf("transfer failed: %v", err) - } - assertEqual(t, body["owner_name"], "target-org") -} - -func TestRepoTransferDryRunDoesNotCallAPI(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{ - "target-owner": "target-org", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("transfer dry-run failed: %v", err) - } -} - -func TestRepoTransferRequiresExplicitYes(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("transfer without --yes should not call API, got %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{"target-owner": "target-org"}) - if err == nil { - t.Fatal("expected confirmation error for missing --yes") - } -} - -func TestRepoTransferFailsWithoutTargetOwner(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call should be made") - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{}) - if err == nil { - t.Fatal("expected error for missing target owner") - } -} - -func TestRepoTransferRejectsBlankTargetOwner(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call should be made") - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{"target-owner": " "}) - if err == nil { - t.Fatal("expected error for blank target owner") - } -} - -func TestRepoTransferCancel(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json") - writeJSON(t, w, map[string]interface{}{"status": "canceled"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "transfer-cancel", map[string]string{"yes": "true"}); err != nil { - t.Fatalf("transfer-cancel failed: %v", err) - } -} - -func TestRepoTransferCancelDryRunDoesNotCallAPI(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer-cancel", map[string]string{"dry-run": "true"}) - if err != nil { - t.Fatalf("transfer-cancel dry-run failed: %v", err) - } -} - -func TestRepoTransferCancelRequiresExplicitYes(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("transfer-cancel without --yes should not call API, got %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer-cancel", nil) - if err == nil { - t.Fatal("expected confirmation error for missing --yes") - } -} +// --- create --- func TestRepoCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/users/me.json" && r.Method == "GET": - writeJSON(t, w, map[string]interface{}{ + writeJSON(w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST": - writeJSON(t, w, map[string]interface{}{"name": "new-repo"}) + writeJSON(w, map[string]interface{}{"name": "new-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -1033,15 +181,13 @@ func TestRepoCreateWithOptions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/users/me.json": - writeJSON(t, w, map[string]interface{}{ + writeJSON(w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case "/creator/my-repo.json": - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"name": "my-repo"}) + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{"name": "my-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -1064,70 +210,6 @@ func TestRepoCreateWithOptions(t *testing.T) { } } -// --- validation/error paths --- - -func TestRepoInsightValidation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - cases := []struct { - name string - shortcut string - args map[string]string - }{ - { - name: "invalid pass year", - shortcut: "contributor-stats", - args: map[string]string{"pass-year": "0"}, - }, - { - name: "invalid start timestamp", - shortcut: "watchers", - args: map[string]string{"start-at": "abc"}, - }, - { - name: "start after end", - shortcut: "stargazers", - args: map[string]string{"start-at": "20", "end-at": "10"}, - }, - { - name: "invalid project id", - shortcut: "follow", - args: map[string]string{"project-id": "repo"}, - }, - { - name: "negative project id", - shortcut: "unlike", - args: map[string]string{"project-id": "-1"}, - }, - { - name: "raw parent path", - shortcut: "raw", - args: map[string]string{"path": "../LICENSE"}, - }, - { - name: "file exists parent path", - shortcut: "file-exists", - args: map[string]string{"path": "src/../LICENSE"}, - }, - { - name: "unknown manifest kind", - shortcut: "manifest", - args: map[string]string{"kind": "ruby"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil { - t.Fatal("expected validation error") - } - }) - } -} - func TestRepoCreateFailsWithoutName(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call should be made") @@ -1140,9 +222,12 @@ func TestRepoCreateFailsWithoutName(t *testing.T) { } } +// --- HTTP error paths --- + func TestRepoListHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() @@ -1154,7 +239,8 @@ func TestRepoListHTTPError(t *testing.T) { func TestRepoInfoHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() @@ -1164,33 +250,10 @@ func TestRepoInfoHTTPError(t *testing.T) { } } -func TestRepoRawHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") - })) - defer server.Close() - - err := runShortcut(t, server, "raw", map[string]string{"path": "README.md"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestRepoFileExistsHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") - })) - defer server.Close() - - err := runShortcut(t, server, "file-exists", map[string]string{"path": "README.md"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - func TestRepoForkHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() @@ -1202,7 +265,8 @@ func TestRepoForkHTTPError(t *testing.T) { func TestRepoDeleteHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() @@ -1214,7 +278,8 @@ func TestRepoDeleteHTTPError(t *testing.T) { func TestRepoCreateGetUserHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() @@ -1226,7 +291,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) { func TestRepoCreateUserNoLogin(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{"user_id": float64(42)}) + writeJSON(w, map[string]interface{}{"user_id": float64(42)}) })) defer server.Close() @@ -1236,386 +301,246 @@ func TestRepoCreateUserNoLogin(t *testing.T) { } } -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } -} +// --- languages --- -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) - } -} - -// --- Repository Settings Shortcuts Tests --- - -func TestRepoDetail(t *testing.T) { +func TestRepoLanguages(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/detail.json") - writeJSON(t, w, map[string]interface{}{ - "name": "repo", - "description": "test repo", - "identifier": "repo", - }) - })) - defer server.Close() - - err := runShortcut(t, server, "detail", nil) - if err != nil { - t.Fatalf("detail failed: %v", err) - } -} - -func TestRepoSimple(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/simple.json") - writeJSON(t, w, map[string]interface{}{ - "name": "repo", - }) - })) - defer server.Close() - - err := runShortcut(t, server, "simple", nil) - if err != nil { - t.Fatalf("simple failed: %v", err) - } -} - -func TestRepoSettings(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/edit.json") - writeJSON(t, w, map[string]interface{}{ - "name": "repo", - "description": "test repo", - "private": false, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "settings", nil) - if err != nil { - t.Fatalf("settings failed: %v", err) - } -} - -func TestRepoUnits(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/project_units.json") - writeJSON(t, w, []interface{}{ - map[string]interface{}{"type": "code"}, - map[string]interface{}{"type": "issues"}, - map[string]interface{}{"type": "pulls"}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "units", nil) - if err != nil { - t.Fatalf("units failed: %v", err) - } -} - -func TestRepoUnitsUpdate(t *testing.T) { - var body map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/project_units.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) } - writeJSON(t, w, map[string]interface{}{"status": 0}) - })) - defer server.Close() - - err := runShortcut(t, server, "units-update", map[string]string{ - "units": "code,issues,pulls", - }) - if err != nil { - t.Fatalf("units-update failed: %v", err) - } - - unitTypes, ok := body["unit_types"].([]interface{}) - if !ok { - t.Fatalf("unit_types not found in body") - } - if len(unitTypes) != 3 { - t.Fatalf("expected 3 unit types, got %d", len(unitTypes)) - } -} - -func TestRepoUnitsUpdateDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "units-update", map[string]string{ - "units": "code,issues", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("units-update dry-run failed: %v", err) - } -} - -func TestRepoUnitsUpdateValidation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("validation error should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - cases := []struct { - name string - args map[string]string - }{ - { - name: "empty units", - args: map[string]string{"units": ""}, - }, - { - name: "invalid unit type", - args: map[string]string{"units": "code,invalid"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := runShortcut(t, server, "units-update", tc.args) - if err == nil { - t.Fatal("expected validation error") - } + if r.URL.Path != "/owner/repo/languages.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{ + "Go": float64(85.5), + "Shell": float64(14.5), }) + })) + defer server.Close() + + err := runShortcut(t, server, "languages", nil) + if err != nil { + t.Fatalf("languages failed: %v", err) } } -func TestRepoTopics(t *testing.T) { +func TestRepoLanguagesHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/project_topics.json") - assertEqual(t, r.URL.Query().Get("keyword"), "go") - writeJSON(t, w, map[string]interface{}{ - "topics": []interface{}{ - map[string]interface{}{"id": float64(1), "name": "golang"}, + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "languages", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- contributors --- + +func TestRepoContributors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/contributors.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{map[string]interface{}{"login": "alice", "contributions": float64(42)}}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("contributors failed: %v", err) + } +} + +func TestRepoContributorsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- files --- + +func TestRepoFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/files.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"name": "README.md", "type": "file"}, + map[string]interface{}{"name": "src", "type": "dir"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "files", nil) + if err != nil { + t.Fatalf("files failed: %v", err) + } +} + +func TestRepoFilesWithRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != "main" { + t.Fatalf("expected ref=main, got %s", r.URL.Query().Get("ref")) + } + if r.URL.Query().Get("filepath") != "src" { + t.Fatalf("expected filepath=src, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(w, []interface{}{}) + })) + defer server.Close() + + err := runShortcut(t, server, "files", map[string]string{"ref": "main", "path": "src"}) + if err != nil { + t.Fatalf("files with ref failed: %v", err) + } +} + +func TestRepoFilesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "files", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- tags --- + +func TestRepoTags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/tags.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{map[string]interface{}{"name": "v1.0.0"}}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("tags failed: %v", err) + } +} + +func TestRepoTagsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- commits --- + +func TestRepoCommits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/commits.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{ + map[string]interface{}{"sha": "abc123", "message": "initial commit"}, }, }) })) defer server.Close() - err := runShortcut(t, server, "topics", map[string]string{"keyword": "go"}) + err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) if err != nil { - t.Fatalf("topics failed: %v", err) + t.Fatalf("commits failed: %v", err) } } -func TestRepoTopicAdd(t *testing.T) { - requests := 0 - var body map[string]interface{} +func TestRepoCommitsWithFilters(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"id": float64(123)}) - case 2: - assertRequest(t, r, "POST", "/v1/project_topics.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"id": float64(1), "name": "test-topic"}) - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + if r.URL.Query().Get("sha") != "main" { + t.Fatalf("expected sha=main, got %s", r.URL.Query().Get("sha")) } - })) - defer server.Close() - - err := runShortcut(t, server, "topic-add", map[string]string{"name": "test-topic"}) - if err != nil { - t.Fatalf("topic-add failed: %v", err) - } - assertEqual(t, body["name"], "test-topic") - assertEqual(t, body["project_id"], "123") -} - -func TestRepoTopicAddDryRun(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"id": float64(123)}) - default: - t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path) + if r.URL.Query().Get("path") != "src/main.go" { + t.Fatalf("expected path=src/main.go, got %s", r.URL.Query().Get("path")) } - })) - defer server.Close() - - err := runShortcut(t, server, "topic-add", map[string]string{ - "name": "test-topic", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("topic-add dry-run failed: %v", err) - } - assertEqual(t, requests, 1) -} - -func TestRepoTopicDelete(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"id": float64(123)}) - case 2: - assertRequest(t, r, "DELETE", "/v1/project_topics/456.json") - assertEqual(t, r.URL.Query().Get("project_id"), "123") - writeJSON(t, w, map[string]interface{}{"status": 0}) - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - err := runShortcut(t, server, "topic-delete", map[string]string{"id": "456"}) - if err != nil { - t.Fatalf("topic-delete failed: %v", err) - } - assertEqual(t, requests, 2) -} - -func TestRepoTopicDeleteDryRun(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"id": float64(123)}) - default: - t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - err := runShortcut(t, server, "topic-delete", map[string]string{ - "id": "456", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("topic-delete dry-run failed: %v", err) - } - assertEqual(t, requests, 1) -} - -func TestRepoTransferOrgs(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/applied_transfer_projects/organizations.json") - writeJSON(t, w, []interface{}{ - map[string]interface{}{"name": "org1"}, - map[string]interface{}{"name": "org2"}, + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{}, }) })) defer server.Close() - err := runShortcut(t, server, "transfer-orgs", nil) - if err != nil { - t.Fatalf("transfer-orgs failed: %v", err) - } -} - -func TestRepoTransfer(t *testing.T) { - var body map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"status": 0}) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{"owner": "new-owner"}) - if err != nil { - t.Fatalf("transfer failed: %v", err) - } - assertEqual(t, body["owner_name"], "new-owner") -} - -func TestRepoTransferDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{ - "owner": "new-owner", - "dry-run": "true", + err := runShortcut(t, server, "commits", map[string]string{ + "sha": "main", + "path": "src/main.go", + "page": "1", + "limit": "20", }) if err != nil { - t.Fatalf("transfer dry-run failed: %v", err) + t.Fatalf("commits with filters failed: %v", err) } } -func TestRepoTransferCancel(t *testing.T) { +func TestRepoCommitsHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json") - writeJSON(t, w, map[string]interface{}{"status": 0}) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) })) defer server.Close() - err := runShortcut(t, server, "transfer-cancel", nil) - if err != nil { - t.Fatalf("transfer-cancel failed: %v", err) - } -} - -func TestRepoTransferCancelDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runShortcut(t, server, "transfer-cancel", map[string]string{"dry-run": "true"}) - if err != nil { - t.Fatalf("transfer-cancel dry-run failed: %v", err) - } -} - -func TestRepoTopicAddFailsWithoutName(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call should be made") - })) - defer server.Close() - - err := runShortcut(t, server, "topic-add", map[string]string{}) + err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) if err == nil { - t.Fatal("expected error for missing name") - } -} - -func TestRepoTopicDeleteFailsWithoutID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call should be made") - })) - defer server.Close() - - err := runShortcut(t, server, "topic-delete", map[string]string{}) - if err == nil { - t.Fatal("expected error for missing id") - } -} - -func TestRepoTransferFailsWithoutOwner(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call should be made") - })) - defer server.Close() - - err := runShortcut(t, server, "transfer", map[string]string{}) - if err == nil { - t.Fatal("expected error for missing owner") + t.Fatal("expected error for HTTP 500") } } diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index ab083ce..ca1594a 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -68,6 +68,78 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "code", + Description: "在仓库中搜索代码", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, + {Name: "owner", Usage: "仓库所有者(可选,限定范围)"}, + {Name: "repo", Usage: "仓库名称(可选,限定范围)"}, + {Name: "language", Usage: "编程语言过滤(如 go, python)"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } + q := url.Values{} + q.Set("keyword", keyword) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if lang := ctx.Arg("language"); lang != "" { + q.Set("language", lang) + } + path := "/search/code" + if owner := ctx.Arg("owner"); owner != "" { + if repo := ctx.Arg("repo"); repo != "" { + path = fmt.Sprintf("/%s/%s/search/code", owner, repo) + } + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "issues", + Description: "搜索 Issue", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, + {Name: "state", Short: "s", Usage: "状态过滤: open/closed/all", Default: "all"}, + {Name: "label", Usage: "标签过滤"}, + {Name: "author", Usage: "作者过滤"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } + q := url.Values{} + q.Set("keyword", keyword) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if state := ctx.Arg("state"); state != "" && state != "all" { + q.Set("state", state) + } + if label := ctx.Arg("label"); label != "" { + q.Set("label", label) + } + if author := ctx.Arg("author"); author != "" { + q.Set("author", author) + } + env, err := ctx.CallAPIWithQuery("GET", "/search/issues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, { Name: "issues", diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 7591f73..535c9c3 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -41,44 +41,81 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "headmaps", - Description: tr.T("cmd.user.headmaps.short"), + Name: "heatmap", + Description: "Show user contribution heatmap", Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "year", Short: "y", Usage: "Year (e.g. 2026)"}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/headmaps", login), nil) + path := fmt.Sprintf("/users/%s/headmaps", login) + if year := ctx.Arg("year"); year != "" { + q := url.Values{} + q.Set("year", year) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stats", + Description: "Show user development statistics", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "end-time", Usage: "End date (YYYY-MM-DD)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + path := fmt.Sprintf("/users/%s/statistics/develop", login) + q := url.Values{} + if st := ctx.Arg("start-time"); st != "" { + q.Set("start_time", st) + } + if et := ctx.Arg("end-time"); et != "" { + q.Set("end_time", et) + } + if len(q) > 0 { + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) if err != nil { return err } return ctx.Output(env) }, }, - newStatsShortcut(tr, "stats-activity", tr.T("cmd.user.stats_activity.short"), "activity"), - newStatsShortcut(tr, "stats-develop", tr.T("cmd.user.stats_develop.short"), "develop"), - newStatsShortcut(tr, "stats-role", tr.T("cmd.user.stats_role.short"), "role"), - newStatsShortcut(tr, "stats-major", tr.T("cmd.user.stats_major.short"), "major"), { Name: "trends", - Description: tr.T("cmd.user.trends.short"), + Description: "Show user project trends", Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Usage: tr.T("flag.limit"), Default: "20"}, + {Name: "login", Short: "l", Usage: "User login name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") if err != nil { return err } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/project_trends", login), q) + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/project_trends", login), nil) if err != nil { return err } @@ -88,29 +125,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } -// newStatsShortcut 生成用户统计类 shortcut,消除 stats-activity/develop/role/major 的重复代码。 -func newStatsShortcut(tr *i18n.Translator, name, desc, subPath string) *common.Shortcut { - return &common.Shortcut{ - Name: name, - Description: desc, - Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - login, err := ctx.RequireArg("login") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", - fmt.Sprintf("/users/%s/statistics/%s", login, subPath), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - } -} - func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 7e242bf..d62b3ee 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -210,3 +210,192 @@ func TestUserTrendsDefaultPagination(t *testing.T) { t.Fatalf("trends failed: %v", err) } } + +// --- heatmap --- + +func TestUserHeatmap(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "" { + t.Fatalf("expected no year query param, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{ + map[string]interface{}{"date": "2026-01-01", "count": float64(5)}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("heatmap failed: %v", err) + } +} + +func TestUserHeatmapWithYear(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "2025" { + t.Fatalf("expected year=2025, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice", "year": "2025"}) + if err != nil { + t.Fatalf("heatmap with year failed: %v", err) + } +} + +func TestUserHeatmapMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserHeatmapHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- stats --- + +func TestUserStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "" || r.URL.Query().Get("end_time") != "" { + t.Fatal("expected no time query params") + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(10), + "commit_count": float64(42), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("stats failed: %v", err) + } +} + +func TestUserStatsWithTimeRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "2026-01-01" { + t.Fatalf("expected start_time=2026-01-01, got %s", r.URL.Query().Get("start_time")) + } + if r.URL.Query().Get("end_time") != "2026-03-31" { + t.Fatalf("expected end_time=2026-03-31, got %s", r.URL.Query().Get("end_time")) + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(5), + "commit_count": float64(20), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{ + "login": "alice", + "start-time": "2026-01-01", + "end-time": "2026-03-31", + }) + if err != nil { + t.Fatalf("stats with time range failed: %v", err) + } +} + +func TestUserStatsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserStatsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- trends --- + +func TestUserTrends(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/project_trends.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"id": float64(1), "name": "created project"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("trends failed: %v", err) + } +} + +func TestUserTrendsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserTrendsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 1c0c8e6..870f51a 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -1,77 +1,63 @@ package wiki import ( - "encoding/base64" "fmt" - "net/url" - "strconv" - "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const wikiBaseURL = "https://gateway.gitlink.org.cn/api" - -// Shortcuts returns wiki page management shortcuts. +// Shortcuts returns wiki management shortcuts for GitLink. +// +// The wiki domain provides commands for listing, viewing, creating, +// updating, and deleting wiki pages within a repository. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { - Name: "list", - Description: "List wiki pages", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - projectID, err := fetchProjectID(ctx) - if err != nil { - return err - } - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("projectId", strconv.Itoa(projectID)) - return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q) - }, - }, - { - Name: "view", - Description: "View a wiki page", - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, - }, + Name: "pages", + Description: "列出 Wiki 页面", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, err := ctx.RequireArg("name") + env, err := ctx.CallAPI("GET", "/api/wiki/wikiPages", nil) if err != nil { return err } - projectID, err := fetchProjectID(ctx) + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "获取 Wiki 页面内容", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") if err != nil { return err } - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("projectId", strconv.Itoa(projectID)) - q.Set("pageName", name) - return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q) + path := fmt.Sprintf("/api/wiki/getWiki?id=%s", id) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) }, }, { Name: "create", - Description: "Create a wiki page", + Description: "创建 Wiki 页面", Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, - {Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true}, - {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "title", Short: "t", Usage: "页面标题", Required: true}, + {Name: "content", Short: "c", Usage: "页面内容(Markdown)", Required: true}, + {Name: "project", Usage: "项目 ID"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, err := ctx.RequireArg("name") + title, err := ctx.RequireArg("title") if err != nil { return err } @@ -79,128 +65,65 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - projectID, err := fetchProjectID(ctx) + body := map[string]interface{}{ + "title": title, + "content": content, + } + if project := ctx.Arg("project"); project != "" { + body["project_id"] = project + } + env, err := ctx.CallAPI("POST", "/api/wiki/createWiki", body) if err != nil { return err } - body := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": projectID, - "pageName": name, - "title": name, - "message": ctx.Arg("message"), - "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), - } - return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil) + return ctx.Output(env) }, }, { Name: "update", - Description: "Update a wiki page", + Description: "更新 Wiki 页面", Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, - {Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true}, - {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "title", Short: "t", Usage: "新标题"}, + {Name: "content", Short: "c", Usage: "新内容(Markdown)"}, }, Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") + id, err := ctx.RequireArg("id") if err != nil { return err } - content, err := ctx.RequireArg("content") + body := map[string]interface{}{"id": id} + if t := ctx.Arg("title"); t != "" { + body["title"] = t + } + if c := ctx.Arg("content"); c != "" { + body["content"] = c + } + env, err := ctx.CallAPI("PUT", "/api/wiki/updateWiki", body) if err != nil { return err } - projectID, err := fetchProjectID(ctx) - if err != nil { - return err - } - body := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": projectID, - "pageName": name, - "title": name, - "message": ctx.Arg("message"), - "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), - } - return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil) + return ctx.Output(env) }, }, { Name: "delete", - Description: "Delete a wiki page", + Description: "删除 Wiki 页面", Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") + id, err := ctx.RequireArg("id") if err != nil { return err } - projectID, err := fetchProjectID(ctx) + body := map[string]interface{}{"id": id} + env, err := ctx.CallAPI("POST", "/api/wiki/deleteWiki", body) if err != nil { return err } - body := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": projectID, - "pageName": name, - } - return callWikiAPI(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil) + return ctx.Output(env) }, }, } } - -// callWikiAPI temporarily switches the client BaseURL to the wiki gateway. -// In test mode (BaseURL is a local httptest server), the switch is skipped. -func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { - origBase := ctx.Client.BaseURL - if !strings.HasPrefix(origBase, "http://127.0.0.1") { - ctx.Client.BaseURL = wikiBaseURL - } - defer func() { ctx.Client.BaseURL = origBase }() - - if query != nil { - env, err := ctx.CallAPIRawWithQuery(method, path, query) - if err != nil { - return err - } - return ctx.Output(env) - } - env, err := ctx.CallAPIRaw(method, path, body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func fetchProjectID(ctx *common.RuntimeContext) (int, error) { - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return 0, fmt.Errorf("获取项目信息失败: %w", err) - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("无法解析项目信息") - } - if idFloat, ok := data["project_id"].(float64); ok { - return int(idFloat), nil - } - if idFloat, ok := data["repo_id"].(float64); ok { - return int(idFloat), nil - } - if idFloat, ok := data["id"].(float64); ok { - return int(idFloat), nil - } - return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在") -} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 8907282..7712815 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,171 +1,281 @@ package wiki import ( - "encoding/base64" + "encoding/json" "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestWikiList(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{ - "id": float64(123), - "name": "repo", - }) - case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages": - common.WriteJSON(t, w, map[string]interface{}{ - "data": []interface{}{ - map[string]interface{}{"title": "Home", "sub_url": "Home"}, - map[string]interface{}{"title": "Guide", "sub_url": "Guide"}, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() +func TestWikiPages(t *testing.T) { + tests := []struct { + name string + mockStatus int + mockBody string + wantErr bool + errContains string + }{ + {"正常返回", 200, `{"wikiPages": []}`, false, ""}, + {"API 404", 404, `{"error": "not found"}`, true, "404"}, + {"返回 HTML", 200, `Login`, true, "HTML"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) + defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "list", ctx) - if err != nil { - t.Fatalf("list failed: %v", err) + shortcut := findWikiShortcut(t, "pages") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("错误应包含 %q: %s", tt.errContains, err.Error()) + } + } + }) } } -func TestWikiView(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{ - "id": float64(123), - }) - case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki": - pageName := r.URL.Query().Get("pageName") - if pageName != "Home" { - t.Fatalf("expected pageName=Home, got %s", pageName) - } - common.WriteJSON(t, w, map[string]interface{}{ - "data": map[string]interface{}{ - "title": "Home", - "content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome")), - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() +func TestWikiGet(t *testing.T) { + tests := []struct { + name string + args map[string]string + mockStatus int + mockBody string + wantErr bool + errContains string + }{ + {"正常获取", map[string]string{"id": "42"}, 200, `{"id": 42, "title": "Home"}`, false, ""}, + {"缺少 id", map[string]string{}, 200, `{}`, true, ""}, + {"API 404", map[string]string{"id": "999"}, 404, `{"error": "not found"}`, true, "404"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/api/wiki/getWiki") { + t.Errorf("expected path containing /api/wiki/getWiki, got %s", r.URL.Path) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) + defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "name": "Home", - }) - err := common.RunShortcut(t, Shortcuts(), "view", ctx) - if err != nil { - t.Fatalf("view failed: %v", err) + shortcut := findWikiShortcut(t, "get") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: tt.args, + } + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + }) } } func TestWikiCreate(t *testing.T) { - var createPayload map[string]interface{} - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{ - "id": float64(123), - }) - case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki": - createPayload = common.DecodeJSON(t, r) - common.WriteJSON(t, w, map[string]interface{}{ - "code": 201, - "data": map[string]interface{}{"title": "NewPage"}, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) } - }) + if !strings.Contains(r.URL.Path, "/api/wiki/createWiki") { + t.Errorf("expected path containing /api/wiki/createWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "name": "NewPage", - "content": "Hello Wiki", - }) - err := common.RunShortcut(t, Shortcuts(), "create", ctx) - if err != nil { - t.Fatalf("create failed: %v", err) + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "title": "Getting Started", + "content": "# Hello\nWelcome to the wiki", + }, } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["title"] != "Getting Started" { + t.Errorf("expected title 'Getting Started', got %v", payload["title"]) + } + if payload["content"] != "# Hello\nWelcome to the wiki" { + t.Errorf("unexpected content: %v", payload["content"]) + } +} - common.AssertEqual(t, createPayload["pageName"], "NewPage") - common.AssertEqual(t, createPayload["owner"], "owner") - common.AssertEqual(t, createPayload["repo"], "repo") +func TestWikiCreateWithProject(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0}`)) + })) + defer server.Close() - expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki")) - common.AssertEqual(t, createPayload["content_base64"], expectedContent) + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "title": "Test", + "content": "Body", + "project": "123", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["project_id"] != "123" { + t.Errorf("expected project_id '123', got %v", payload["project_id"]) + } +} + +func TestWikiCreateMissingTitle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --title is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"content": "only content"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --title is missing") + } } func TestWikiUpdate(t *testing.T) { - var updatePayload map[string]interface{} - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{ - "id": float64(123), - }) - case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki": - updatePayload = common.DecodeJSON(t, r) - common.WriteJSON(t, w, map[string]interface{}{ - "code": 200, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT, got %s", r.Method) } - }) + if !strings.Contains(r.URL.Path, "/api/wiki/updateWiki") { + t.Errorf("expected path containing /api/wiki/updateWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "name": "Home", - "content": "Updated content", - "message": "Update wiki page", - }) - err := common.RunShortcut(t, Shortcuts(), "update", ctx) - if err != nil { - t.Fatalf("update failed: %v", err) + shortcut := findWikiShortcut(t, "update") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "id": "42", + "title": "Updated Title", + "content": "Updated content", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + if payload["id"] != "42" { + t.Errorf("expected id '42', got %v", payload["id"]) + } + if payload["title"] != "Updated Title" { + t.Errorf("expected title 'Updated Title', got %v", payload["title"]) } - - common.AssertEqual(t, updatePayload["pageName"], "Home") - common.AssertEqual(t, updatePayload["message"], "Update wiki page") } func TestWikiDelete(t *testing.T) { - var deletePayload map[string]interface{} - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo.json": - common.WriteJSON(t, w, map[string]interface{}{ - "id": float64(123), - }) - case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki": - deletePayload = common.DecodeJSON(t, r) - common.WriteJSON(t, w, map[string]interface{}{ - "code": 204, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) } - }) + if !strings.Contains(r.URL.Path, "/api/wiki/deleteWiki") { + t.Errorf("expected path containing /api/wiki/deleteWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "name": "OldPage", - }) - err := common.RunShortcut(t, Shortcuts(), "delete", ctx) - if err != nil { - t.Fatalf("delete failed: %v", err) + shortcut := findWikiShortcut(t, "delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"id": "42"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + if payload["id"] != "42" { + t.Errorf("expected id '42', got %v", payload["id"]) } - - common.AssertEqual(t, deletePayload["pageName"], "OldPage") - common.AssertEqual(t, deletePayload["projectId"], float64(123)) +} + +func TestWikiDeleteMissingId(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --id is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --id is missing") + } +} + +func findWikiShortcut(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 decodeWikiJSON(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 } diff --git a/skills/gitlink-ci-health/EXAMPLES.md b/skills/gitlink-ci-health/EXAMPLES.md new file mode 100644 index 0000000..af237f8 --- /dev/null +++ b/skills/gitlink-ci-health/EXAMPLES.md @@ -0,0 +1,178 @@ +# gitlink-ci-health 使用样例 + +## 样例 1:CI 未激活的仓库 + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +**CLI 版本**:gitlink-cli 0.1.18 + +### 执行流程 + +```bash +# Step 1: 检查 CI 状态(方法 1 — repo +info) +gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json +# → "open_devops": false ← CI 未激活 + +# Step 1 补充(方法 2 — ci +builds) +gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json +# → {"status":-1,"message":"接口数据异常"} ← 确认 CI 未激活 + +# 此时终止后续步骤,生成"CI 未激活"报告 +``` + +### 关键发现 + +| 项目 | 值 | +|------|-----| +| `open_devops` | `false` | +| `ci +builds` 返回 | `{"status": -1, "message": "接口数据异常"}` | +| 可用 CI 命令 | `+builds`、`+logs`、`+restart`、`+stop` | +| 不存在的命令 | `ci +authorize`、`ci +activate`、`ci +deactivate` | + +### 诊断结论 + +CI 完全未启用,需通过 GitLink Web 界面开启(仓库设置 → DevOps)。用户拥有 Manager 权限,可以操作。 + +### 生成的报告 + +```markdown +# 🔧 CI 健康巡检报告:jiangtx/gitlink-cli + +> 巡检时间:2026-06-03 +> 仓库:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +> CI 状态:❌ 未激活 + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | ❌ 未激活(open_devops: false) | 0/4 | +| 整体成功率 | N/A | —/5 | +| 近期稳定性 | N/A | —/5 | +| 构建频率 | N/A | —/3 | +| 修复速度 | N/A | —/3 | +| **总分** | | **0/20** | + +## 二、诊断详情 + +API 调用 ci +builds 返回: +{"status": -1, "message": "接口数据异常"} + +仓库元数据显示 open_devops: false,确认该仓库尚未启用 GitLink 平台的 CI/CD(DevOps)服务。 + +## 三、改进建议 + +- 🔴 立即激活 CI:前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务 +- 🟡 配置 CI Pipeline:建议添加 .gitlink-ci.yml 配置编译和测试流水线 + +## 四、仓库基本信息 + +| 项目 | 值 | +|------|-----| +| 默认分支 | master | +| 仓库大小 | 13.4 MB | +| 贡献者 | 2 | +| PR 数量 | 9 | +| 权限 | Manager | +``` + +--- + +## 异常场景速查 + +| 场景 | 检测方式 | `ci +builds` 返回值 | 处理 | +|------|----------|---------------------|------| +| CI 未激活 | `repo +info` 的 `open_devops: false` | `{"status":-1,"message":"接口数据异常"}` | 建议 Web 界面激活,终止巡检 | +| CI 已激活但无构建 | `repo +info` 的 `open_devops: true` + builds 为空 | `[]` 或空列表 | 标注"暂无构建记录" | +| 构建样本不足(<5) | builds 列表长度 < 5 | 正常 JSON 数组 | 标注"数据有限,不具代表性" | + +--- + +## 版本兼容性说明 + +本 skill 基于 `gitlink-cli 0.1.18` 编写。不同版本的 CI 子命令可能有差异: + +| CLI 版本 | 可用 CI 命令 | +|----------|-------------| +| 0.1.18 | `+builds`、`+logs`、`+restart`、`+stop` | +| 未来版本 | 可能新增 `+activate`、`+deactivate` 等 | + +当 CLI 版本更新后,重新验证可用命令: +```bash +gitlink-cli ci --help +``` + +--- + +## 样例 2:通过 Agent 调用 Skill(自动巡检) + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli +**调用方式**:`Agent(subagent_type="general-purpose", prompt="调用 gitlink-ci-health skill,检查 jiangtx/gitlink-cli 的 CI 状态。严格按照 skill 的工作流步骤执行。")` + +### Agent 自主执行的命令序列 + +``` +工具调用 1: gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json + → open_devops: false ← 发现 CI 未激活 + +工具调用 2: gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json + → {"status": -1, "message": "接口数据异常"} ← 二次确认 +``` + +### Agent 决策过程 + +Agent 读取到 `open_devops: false` 后,**正确遵循了 skill 的异常场景处理规则**: + +> "CI 未激活 → 报告 CI 状态为'未激活',建议通过 Web 界面开启,**不再继续后续步骤**" + +Agent 没有继续尝试 `ci +logs` 或统计分析,直接生成了完整报告。共消耗 **5 次工具调用**,**32,113 tokens**,耗时 **35.8 秒**。 + +### Agent 生成的报告 + +```markdown +# CI 健康巡检报告:gitlink-cli + +> 巡检时间:2026-06-03 +> 仓库:jiangtx/gitlink-cli +> CI 状态:未激活 + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | 未激活 | 0/4 | +| 整体成功率 | N/A | -/5 | +| 近期稳定性 | N/A | -/5 | +| 构建频率 | N/A | -/3 | +| 修复速度 | N/A | -/3 | +| **总分** | | **0/20** | + +## 二、构建趋势 + +无构建记录。仓库 CI 未启用。 + +## 三、故障分析 + +CI 未激活,无构建数据可供分析。 + +## 四、改进建议 + +- **立即激活 CI**:前往 GitLink Web 界面 → 仓库设置 → DevOps,开启 CI/CD 服务。 + (CLI 暂不支持 ci +activate 命令,需要通过 Web 界面操作。) + +--- + +总结:仓库 jiangtx/gitlink-cli(项目 ID: 1547588,默认分支: master,权限: Manager) +当前 open_devops 字段为 false,CI/CD 服务未开启。其他仓库信息:2 位贡献者、 +9 个 PR、0 个 Issue,Fork 自 Gitlink/gitlink-cli。 +``` + +### 验证结论 + +✅ skill v1.1.0 修复验证通过: +- Agent 正确使用了 `repo +info` 的 `open_devops` 字段判断 CI 状态 +- Agent 在 CI 未激活时正确终止,没有执行后续无效步骤 +- Agent 没有尝试调用不存在的 `ci +authorize` 或 `ci +activate` +- Agent 正确建议通过 Web 界面激活 +- 报告结构完整,包含了仓库基本信息 diff --git a/skills/gitlink-ci-health/SKILL.md b/skills/gitlink-ci-health/SKILL.md new file mode 100644 index 0000000..8721c8b --- /dev/null +++ b/skills/gitlink-ci-health/SKILL.md @@ -0,0 +1,189 @@ +--- +name: gitlink-ci-health +version: 1.1.0 +description: "CI 健康巡检:检查仓库 CI/CD 授权状态、构建历史和成功率,生成 CI 健康度报告。当用户需要检查 CI 状态、分析构建成功率、排查 CI 故障时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli ci --help" +--- + +# gitlink-ci-health(CI 健康巡检) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作。CI 激活/关闭需通过 GitLink Web 界面操作,CLI 不提供对应命令。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +面向维护者的 CI/CD 健康度巡检工具: + +1. **授权检查** — 确认仓库 CI 是否已激活 +2. **构建历史** — 获取近期构建列表 +3. **成功率统计** — 计算构建成功率和平均耗时 +4. **故障分析** — 识别频繁失败的构建及其原因 +5. **健康报告** — 生成 CI 健康度评分和改进建议 + +--- + +## 工作流:CI 健康巡检 + +### Step 1:检查 CI 授权状态 + +**方法 1(推荐)**:通过 `repo +info` 查看 `open_devops` 字段: + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +- `"open_devops": true` → CI 已激活 +- `"open_devops": false` → CI 未激活 + +**方法 2**:直接调用 `ci +builds`,CI 未激活时返回: + +```json +{"status": -1, "message": "接口数据异常"} +``` + +> ⚠️ `ci +authorize` 命令在当前 CLI 版本(v0.1.18)中**不存在**。可用 CI 命令仅:`+builds`、`+logs`、`+restart`、`+stop`。 + +若 CI 未激活,报告中说明"CI 未启用",建议通过 GitLink Web 界面(仓库设置 → DevOps)开启,随后不再继续后续步骤。 + +### Step 2:获取构建历史 + +```bash +gitlink-cli ci +builds --owner --repo --format json +``` + +提取每次构建的: +- `status` — 构建状态(success/failed/running/pending) +- `created_at` / `finished_at` — 时间信息 +- `duration` — 耗时(如有) +- `branch` — 触发分支 + +如构建数量 >30,取最近 30 次分析。 + +### Step 3:构建日志(失败构建) + +对状态为 failed 的构建获取日志: + +```bash +gitlink-cli ci +logs --owner --repo --build --format json +``` + +> ⚠️ **控制调用量**:仅对最近 5 次失败构建获取日志,避免过多 API 调用。日志可能过大,提取关键错误行(最后 20 行)。 + +### Step 4:统计分析 + +#### 4.1 成功率计算 + +| 指标 | 计算方式 | +|------|----------| +| 整体成功率 | 成功构建数 / 总构建数 × 100% | +| 近 10 次成功率 | 最近 10 次中成功占比 | +| 平均修复时间 | 从失败到下次成功的平均间隔 | + +#### 4.2 健康度评分(满分 20) + +| 维度 | 权重 | 评分标准 | +|------|------|----------| +| CI 激活 | 4 | 已激活=4,未激活=0 | +| 构建成功率 | 5 | ≥90%=5,≥80%=4,≥70%=3,≥50%=2,<50%=1 | +| 近期稳定性 | 5 | 近10次全部成功=5,8-9次=4,6-7次=3,4-5次=2,<4次=1 | +| 构建频率 | 3 | 每天有构建=3,2-3天=2,每周=1,更少=0 | +| 修复速度 | 3 | 失败后1次内修复=3,2-3次=2,>3次=1 | + +### Step 5:生成 CI 健康报告 + +--- + +## 输出模板 + +```markdown +# 🔧 CI 健康巡检报告:{{仓库名}} + +> 巡检时间:{{当前时间}} +> 仓库:{{full_name}} +> CI 状态:{{ci_status_display}} + +--- + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | {{activated_status}} | {{activate_score}}/4 | +| 整体成功率 | {{success_rate}}%({{success_count}}/{{total_count}}) | {{success_score}}/5 | +| 近期稳定性 | 近 10 次 {{recent_success}} 次成功 | {{stability_score}}/5 | +| 构建频率 | {{build_frequency_desc}} | {{frequency_score}}/3 | +| 修复速度 | {{repair_speed_desc}} | {{repair_score}}/3 | +| **总分** | | **{{total_score}}/20** | + +## 二、构建趋势 + +``` +最近 20 次构建: +✅✅❌✅✅✅❌✅✅✅✅✅❌✅✅✅✅✅✅ +(✅=成功 ❌=失败) +``` + +| 时间段 | 总构建 | 成功 | 失败 | 成功率 | +|--------|--------|------|------|--------| +| 最近 7 天 | {{w1_total}} | {{w1_success}} | {{w1_fail}} | {{w1_rate}}% | +| 7-14 天 | {{w2_total}} | {{w2_success}} | {{w2_fail}} | {{w2_rate}}% | +| 14-30 天 | {{w3_total}} | {{w3_success}} | {{w3_fail}} | {{w3_rate}}% | + +## 三、故障分析 + +> 如无失败构建,输出:**🎉 分析期内无失败构建,CI 运行健康。** + +| 构建 ID | 分支 | 失败时间 | 错误摘要 | +|---------|------|----------|----------| +| {{id}} | {{branch}} | {{time}} | {{error_summary}} | + +### 故障模式分类 + +| 故障类型 | 次数 | 占比 | +|----------|------|------| +| 编译错误 | {{compile_count}} | {{compile_pct}}% | +| 测试失败 | {{test_fail_count}} | {{test_fail_pct}}% | +| 超时 | {{timeout_count}} | {{timeout_pct}}% | +| 环境问题 | {{env_count}} | {{env_pct}}% | +| 其他 | {{other_count}} | {{other_pct}}% | + +## 四、改进建议 + + + +- **立即激活 CI**(当 CI 未激活时):前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务(CLI 暂不支持 `ci +activate`) +- **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因 +- **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI +- **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| CI 未激活 | 报告 CI 状态为"未激活",建议通过 Web 界面开启,不再继续后续步骤 | +| 无构建记录 | 标注"仓库暂无 CI 构建记录" | +| `ci +logs` 返回空 | 标注"日志不可用" | +| 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **CI 激活/关闭需通过 GitLink Web 界面**,CLI 不提供 `+activate`/`+deactivate` 命令 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行 +- ⚠️ **构建历史无分页参数**,实际返回条数取决于 API +- ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务 +- ⚠️ **`repo +info` 的 `open_devops` 字段**是判断 CI 是否激活的最可靠方式 diff --git a/skills/gitlink-ci/SKILL.md b/skills/gitlink-ci/SKILL.md index e335526..385ae12 100644 --- a/skills/gitlink-ci/SKILL.md +++ b/skills/gitlink-ci/SKILL.md @@ -24,9 +24,6 @@ metadata: | `ci +logs` | 构建日志 | 是 | | `ci +restart` | 重启构建 | 是 | | `ci +stop` | 停止构建 | 是 | -| `ci +enable` | 启用 CI | 是 | -| `ci +disable` | 停用 CI | 是 | -| `ci +authorize` | CI 授权状态 | 是 | ## 使用示例 @@ -42,18 +39,17 @@ gitlink-cli ci +restart --build 42 # 停止构建 gitlink-cli ci +stop --build 42 - -# 启用 CI(需先配置 .gitlink-ci.yml 流水线文件) -gitlink-cli ci +enable --owner myuser --repo myrepo - -# 停用 CI -gitlink-cli ci +disable --owner myuser --repo myrepo - -# 查看 CI 授权状态 -gitlink-cli ci +authorize --owner myuser --repo myrepo ``` -## 注意事项 +## Raw API 补充 -- `ci +enable` 需要仓库已配置 `.gitlink-ci.yml` 流水线文件,否则返回 -1 -- 构建操作需要仓库已启用 CI,可通过 `ci +authorize` 查看状态 +```bash +# 激活 CI +gitlink-cli ci +activate + +# 停用 CI +gitlink-cli ci +deactivate + +# CI 授权状态 +gitlink-cli ci +authorize +``` diff --git a/skills/gitlink-code-review/REFERENCE.md b/skills/gitlink-code-review/REFERENCE.md index 0011473..f47959b 100644 --- a/skills/gitlink-code-review/REFERENCE.md +++ b/skills/gitlink-code-review/REFERENCE.md @@ -160,7 +160,7 @@ gitlink-cli repo +info --owner --repo --format json ### 获取仓库文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` **返回字段说明:** @@ -177,7 +177,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=/ +gitlink-cli repo +raw --ref=/ ``` --- @@ -213,7 +213,7 @@ gitlink-cli user +me --format json ### 获取用户信息 ```bash -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json ``` | 字段 | 类型 | 说明 | diff --git a/skills/gitlink-code-review/SKILL.md b/skills/gitlink-code-review/SKILL.md index 8a9cef5..e43b0ab 100644 --- a/skills/gitlink-code-review/SKILL.md +++ b/skills/gitlink-code-review/SKILL.md @@ -109,13 +109,13 @@ gitlink-cli pr +diff --id --format json ```bash # 方式 1:提交整体 Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ +gitlink-cli pr +review --body '{ "body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价:...", "event": "COMMENT" }' # 方式 2:在特定行添加内联评论(逐条提交) -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ +gitlink-cli pr +review --body '{ "body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。", "event": "COMMENT", "commit_id": "", @@ -164,18 +164,18 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ gitlink-cli repo +info --owner --repo --format json # 2. 获取仓库文件列表(遍历关键目录) -gitlink-cli repo +tree --owner --repo --path src --ref master --format json -gitlink-cli repo +tree --owner --repo --path tests --ref master --format json +gitlink-cli repo +files --query 'filepath=src&ref=master' +gitlink-cli repo +files --query 'filepath=tests&ref=master' # 3. 获取关键文件内容 -gitlink-cli repo +raw --owner --repo --path README.md --ref master --format json -gitlink-cli repo +raw --owner --repo --path .gitignore --ref master --format json -gitlink-cli repo +raw --owner --repo --path .eslintrc.js --ref master --format json -gitlink-cli repo +manifest --owner --repo --kind node --ref master --format json +gitlink-cli repo +raw --ref=master/README.md +gitlink-cli repo +raw --ref=master/.gitignore +gitlink-cli repo +raw --ref=master/.eslintrc.js # 或类似配置 +gitlink-cli repo +raw --ref=master/package.json # 或 go.mod, Cargo.toml # 4. 获取语言统计和贡献者 -gitlink-cli api GET /:owner/:repo/languages -gitlink-cli api GET /:owner/:repo/contributors +gitlink-cli repo +languages +gitlink-cli repo +contributors ``` **健康度检查清单:** @@ -233,7 +233,7 @@ gitlink-cli issue +view --id --format json # 3. 根据内容智能分类 # 分析标题和描述后,通过 Raw API 打标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{ +gitlink-cli issue +update --number '{ "issue_tag_ids": [], "done_ratio": 0, "subject": "<原始标题>", @@ -261,28 +261,28 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{ ```bash # 获取 PR 详情 -gitlink-cli api GET /:owner/:repo/pulls/:id --format json +gitlink-cli pr +view --id --format json # 获取 PR 变更文件列表 -gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json +gitlink-cli pr +files --format json # 获取 PR Diff -gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json +gitlink-cli pr +diff --format json # 提交 PR Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}' +gitlink-cli pr +review --body '{"body":"...","event":"COMMENT"}' # 获取仓库文件列表 -gitlink-cli repo +tree --owner --repo --path --ref --format json +gitlink-cli repo +files --query 'filepath=&ref=' # 获取仓库语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json # 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json # 获取仓库动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ## 代码审查最佳实践 diff --git a/skills/gitlink-code-review/examples/pr-review-workflow.md b/skills/gitlink-code-review/examples/pr-review-workflow.md index 2028acc..5409630 100644 --- a/skills/gitlink-code-review/examples/pr-review-workflow.md +++ b/skills/gitlink-code-review/examples/pr-review-workflow.md @@ -117,7 +117,7 @@ gitlink-cli pr +diff --id 42 --format json ```bash # 提交整体 Review 评论 -gitlink-cli api POST /Gitlink/forgeplus/pulls/42/reviews --body '{ +gitlink-cli pr +review --id 42 --owner Gitlink --repo forgeplus --body '{ "body": "## PR #42 代码审查报告\n\n### 🔴 Critical\n\n1. **JWT Secret 硬编码** — `src/config.py:15`\n JWT_SECRET 硬编码在源码中。建议使用 `os.getenv(\"JWT_SECRET\")`。\n\n2. **SQL 注入风险** — `src/auth/login.py:42`\n 直接拼接用户输入到 SQL 查询。建议使用参数化查询。\n\n### 🟡 Warning\n\n1. **密码明文存储** — 建议使用 bcrypt 哈希处理。\n\n### 总体评价\n\n代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。", "event": "COMMENT" }' @@ -160,5 +160,5 @@ gitlink-cli pr +files --id --format json gitlink-cli pr +diff --id --format json # 提交 Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}' +gitlink-cli pr +review --body '{"body":"...","event":"COMMENT"}' ``` diff --git a/skills/gitlink-commit-quality/SKILL.md b/skills/gitlink-commit-quality/SKILL.md index 11a0c78..a06d977 100644 --- a/skills/gitlink-commit-quality/SKILL.md +++ b/skills/gitlink-commit-quality/SKILL.md @@ -59,7 +59,10 @@ metadata: ```bash # 获取 PR 关联的提交列表 -gitlink-cli pr +commits --id --owner --repo --format json +gitlink-cli pr +diff --id --owner --repo --format json + +# 或通过 Raw API 获取提交详情 +gitlink-cli pr +commits --format json ``` ### Commit Message 质量检查清单 diff --git a/skills/gitlink-compliance/REFERENCE.md b/skills/gitlink-compliance/REFERENCE.md index e7ac7c4..af77c01 100644 --- a/skills/gitlink-compliance/REFERENCE.md +++ b/skills/gitlink-compliance/REFERENCE.md @@ -7,7 +7,7 @@ ### 读取文件内容 ```bash -gitlink-cli api GET /:owner/:repo/raw// +gitlink-cli repo +raw --ref=/ ``` **说明:** 直接返回文件原始内容,用于检查 LICENSE、README、CONTRIBUTING 等文件。 @@ -15,7 +15,7 @@ gitlink-cli api GET /:owner/:repo/raw// ### 获取文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` | 字段 | 类型 | 说明 | @@ -45,7 +45,7 @@ gitlink-cli repo +info --owner --repo --format json ### 获取贡献者列表 ```bash -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` 用于检查贡献者是否签署了 CLA/DCO。 diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md index ca85ff1..eeaa25a 100644 --- a/skills/gitlink-compliance/SKILL.md +++ b/skills/gitlink-compliance/SKILL.md @@ -6,38 +6,20 @@ metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli repo --help" - scenario: "S3" --- -# gitlink-compliance(开源合规与复现性检查) +# gitlink-compliance(开源合规检查) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 -子赛题四「应用 GitLink 辅助科研」· 场景 **S3 科研项目合规与复现性检查** 的自动化算法由 -`scripts/research/repro.py`(Go 出数据 + Python 做算法)实现,见 **工作流 4**。 - ---- - -## 何时使用 - -- 科研项目准备开源发布前,做一次全面的合规与复现性审查。 -- 想知道「这个仓库别人能不能复现」:CI、lockfile、README 复现说明、版本 tag、容器环境是否齐备。 -- 想发现仓库里的合规风险与敏感信息泄露:缺 LICENSE / 版权头、数据目录入库、`.env` 泄露、硬编码密钥。 - -## 前置条件 - -1. 已 `gitlink-cli auth login`(Token 7 天有效)。 -2. 目标仓库存在且可读取文件树(`repo +info` / `file +get` / `repo +tree`)。 -3. 复现性自动化检查(工作流 4)只用 Python 标准库,无需第三方依赖。 - --- ## 工作流概览 -本 Skill 提供开源项目的合规性与复现性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。 +本 Skill 提供开源项目的合规性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。 | 检查类型 | 覆盖范围 | 严重程度 | |----------|---------|:--------:| @@ -46,8 +28,6 @@ metadata: | 依赖合规 | 第三方依赖许可证兼容性 | 🔴 | | 安全策略 | SECURITY.md、安全披露流程 | 🟡 | | 贡献者协议 | CLA / DCO 要求 | 🔵 | -| 复现性 | CI / lockfile / README 复现说明 / 版本 tag / 容器环境 | 🟡 | -| 数据隐私 | data/ 入库、.env 泄露、密钥硬编码 | 🔴 | --- @@ -59,10 +39,10 @@ metadata: ```bash # 1. 获取仓库文件结构 -gitlink-cli repo +tree --owner --repo --ref master --format json +gitlink-cli repo +files --query 'filepath=&ref=master' # 2. 读取 LICENSE 文件 -gitlink-cli repo +raw --owner --repo --path LICENSE --ref master --format json +gitlink-cli repo +raw --ref=master/LICENSE # 3. 检查关键文档是否存在 # 检查以下文件是否存在: @@ -74,14 +54,14 @@ gitlink-cli repo +raw --owner --repo --path LICENSE --ref master # - README.md # 4. 获取依赖配置 -gitlink-cli repo +manifest --owner --repo --kind node --ref master --format json -gitlink-cli repo +manifest --owner --repo --kind go --ref master --format json -gitlink-cli repo +manifest --owner --repo --kind python --ref master --format json -gitlink-cli repo +manifest --owner --repo --kind rust --ref master --format json -gitlink-cli repo +manifest --owner --repo --kind java --ref master --format json +gitlink-cli repo +raw --ref=master/package.json # Node.js +gitlink-cli repo +raw --ref=master/go.mod # Go +gitlink-cli repo +raw --ref=master/requirements.txt # Python +gitlink-cli repo +raw --ref=master/Cargo.toml # Rust +gitlink-cli repo +raw --ref=master/pom.xml # Java/Maven # 5. 获取源文件检查(按语言采样) -gitlink-cli repo +tree --owner --repo --path src --ref master --format json +gitlink-cli repo +files --query 'filepath=src&ref=master' # 6. 获取仓库基本信息 gitlink-cli repo +info --owner --repo --format json @@ -166,7 +146,7 @@ gitlink-cli repo +info --owner --repo --format json ```bash # 1. 获取依赖配置文件 -gitlink-cli repo +manifest --owner --repo --kind node --ref master --format json +gitlink-cli repo +raw --ref=master/package.json ``` ### 许可证兼容性参考 @@ -201,10 +181,10 @@ gitlink-cli repo +manifest --owner --repo --kind node --ref maste ```bash # 1. 遍历源文件目录 -gitlink-cli repo +tree --owner --repo --path src --ref master --format json +gitlink-cli repo +files --query 'filepath=src&ref=master' # 2. 采样检查源文件头部(取前 5-10 行) -gitlink-cli repo +raw --owner --repo --path src/main.py --ref master --format json +gitlink-cli repo +raw --ref=master/src/main.py ``` ### 标准版权声明模板 @@ -226,92 +206,20 @@ gitlink-cli repo +raw --owner --repo --path src/main.py --ref mas --- -## 工作流 4:合规与复现性自动化检查(repro.py) - -**场景**:子赛题四·S3 科研项目合规与复现性检查 —— 对一个科研仓库同时给出「合规分」与「复现分」,并产出检查清单、风险项与中文报告。 - -本工作流的算法由 `scripts/research/repro.py` 实现,数据全部经 gitlink-cli 获取(Go 出数据 + Python 做算法)。 - -### 数据采集 - -`repro.py` 内部调用以下 gitlink-cli 命令(已封装在 `collect.py` 中): - -```bash -# 仓库信息(默认分支、版本 tag) -gitlink-cli --owner --repo repo +info --format json - -# 关键文件文本(LICENSE / README / go.mod / requirements.txt / package.json / .gitignore / SECURITY.md / ...) -gitlink-cli --owner --repo file +get --path LICENSE --ref master - -# 根文件树(扫 data/、.env、config、.gitea/.github workflows 等是否存在) -gitlink-cli --owner --repo repo +tree --ref master - -# 语言占比(仅作为元信息记录) -gitlink-cli --owner --repo repo +languages --format json -``` - -### 算法(纯函数,可单测) - -| 函数 | 作用 | -|------|------| -| `identify_license(text)` | 关键词匹配 MulanPSL / Apache / MIT / GPL / LGPL / BSD / ISC / MPL / 无 | -| `scan_secrets(text, file)` | 正则找 private key / AWS token / API key / Slack / GitHub token / JWT / 邮箱 / 手机号 → `[{level,category,file,line,detail}]`(脱敏) | -| `repro_checks(file_texts, tree, repo_info)` | CI 配置、lockfile、README 复现说明、版本 tag、容器化,每项 `{name,pass,score(0-2),evidence}` | -| `compliance_items(license_info, file_texts, tree)` | LICENSE 声明、SECURITY.md、版权头、依赖合规、CONTRIBUTING.md | -| `data_privacy(tree, gitignore_text)` | data/ 入库、.env 入库、.gitignore 是否忽略 .env | - -打分:`repro_score` / `compliance_score` 均为 0-10(各项 0-2 分聚合归一)。 - -### 命令 - -```bash -# 默认输出到 stdout(JSON) -python scripts/research/repro.py --owner mindspore-Ecosystem --repo mindspore - -# 输出两件产物到目录(repro.json + compliance_report.md) -python scripts/research/repro.py --owner --repo --out ./out - -# 可复现脚本(封装了上述流程) -bash skills/gitlink-compliance/examples/compliance-repro-workflow.sh [OUT_DIR] -``` - -### 输出结构(repro.json) - -```json -{ - "scenario": "S3_compliance_reproducibility", - "repo": "owner/repo", - "default_branch": "master", - "license": "MIT", - "repro_items": [{"name": "CI 配置", "pass": true, "score": 2, "evidence": "..."}], - "compliance_items": [{"name": "LICENSE 文件", "pass": true, "score": 2, "evidence": "..."}], - "privacy_items": [{"name": ".env 入库", "pass": true, "score": 2, "evidence": "..."}], - "secrets": [{"level": "critical", "category": "private_key", "file": "config.env", "line": 5, "detail": "..."}], - "risks": [{"area": "secret", "name": "private_key", "file": "...", "level": "critical", "evidence": "..."}], - "repro_score": 8.0, - "compliance_score": 6.0, - "meta": {"key_files_found": ["LICENSE", "README.md"], "tree_size": 42, "languages": {"Python": "99%"}} -} -``` - -`compliance_report.md` 包含:复现性检查清单表、合规性检查清单表、数据隐私检查表、风险项表(按严重程度排序)与打分。 - ---- - ## Raw API 参考 ```bash # 获取文件内容 -gitlink-cli repo +raw --owner --repo --path --ref --format json +gitlink-cli repo +raw --ref=/ # 获取文件列表(遍历目录) -gitlink-cli repo +tree --owner --repo --path --ref --format json +gitlink-cli repo +files --query 'filepath=&ref=' # 获取仓库信息 -gitlink-cli api GET /:owner/:repo --format json +gitlink-cli repo +info --format json # 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` ## 注意事项 diff --git a/skills/gitlink-contributor-insight/EXAMPLES.md b/skills/gitlink-contributor-insight/EXAMPLES.md index 2b90504..b0fb4fb 100644 --- a/skills/gitlink-contributor-insight/EXAMPLES.md +++ b/skills/gitlink-contributor-insight/EXAMPLES.md @@ -31,9 +31,7 @@ gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json # → 0 个 Issue ``` -### 不可用命令确认(gitlink-cli 0.1.18 历史记录) - -> 当前版本已新增 `user +heatmap`、`user +stats`、`user +trends`。下表仅记录本样例在 0.1.18 上的历史执行结果。 +### 不可用命令确认 | 命令 | 结果 | |------|------| @@ -131,8 +129,8 @@ gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json | 贡献者数量 | repo +info | ✅ 可靠 | | PR 贡献数据 | pr +list 全量 | ✅ 可靠 | | 用户信息 | user +info | ✅ 可靠 | -| 贡献热力图 | 0.1.18 未实现,当前版本可用 `user +heatmap` | 版本相关 | -| 统计信息 | 0.1.18 未实现,当前版本可用 `user +stats` | 版本相关 | +| 贡献热力图 | 不可用(命令未实现) | ❌ 缺失 | +| 统计信息 | 不可用(命令未实现) | ❌ 缺失 | | 趋势数据 | PR 时间序列推算 | ⚠️ 推算 | ``` @@ -165,10 +163,10 @@ gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json ### Agent 决策过程 -Agent 读取 skill 后,**正确遵循了当时版本的工作流**: +Agent 读取 skill 后,**正确遵循了更新后的工作流**: 1. **未尝试 `repo +contributors`**:skill 的"命令可用性声明"表标注该命令不可用 -2. **未尝试 `user +heatmap/+stats/+trends`**:0.1.18 中这些命令不可用,直接从 PR 列表推算 +2. **未尝试 `user +heatmap/+stats/+trends`**:skill 标注不可用,直接从 PR 列表推算 3. **未尝试 Raw API**:skill 不推荐此路径,全程使用 Shortcut 命令 4. **正确应用"年轻项目"规则**:识别项目仅 3 天,放宽分级标准,2 人均标记为 🔥 核心 5. **自主增强分析**:Agent 额外分析了工作时段偏好、PR 类型统计、新老比例 @@ -199,9 +197,9 @@ Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人 ✅ skill v1.1.0 验证通过: - Agent 正确遵循了"命令可用性声明",未尝试不可用命令 - Agent 正确从 `pr +list` 提取贡献者数据(替代不存在的 `repo +contributors`) -- Agent 正确从 PR 时间戳推算活跃天数(0.1.18 中 `user +heatmap` 不可用) -- Agent 正确从 PR 聚合获得产出量(0.1.18 中 `user +stats` 不可用) -- Agent 正确从 PR 时间分布判断趋势(0.1.18 中 `user +trends` 不可用) +- Agent 正确从 PR 时间戳推算活跃天数(替代不存在的 `user +heatmap`) +- Agent 正确从 PR 聚合获得产出量(替代不存在的 `user +stats`) +- Agent 正确从 PR 时间分布判断趋势(替代不存在的 `user +trends`) - Agent 正确应用"年轻项目放宽标准"规则 - Agent 正确标注数据来源局限性 - Agent 未使用 `gh` 或其他平台工具 @@ -214,9 +212,9 @@ Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人 | 场景 | 检测方式 | 数据表现 | 处理 | |------|----------|----------|------| | `repo +contributors` 不可用 | 命令返回帮助文本 | 无 `+contributors` 子命令 | 从 `pr +list` 提取 `author_login` | -| `user +heatmap` 返回空或权限不足 | 无热力图数据 | API 响应为空或无权限 | 从 PR 时间戳推算活跃天数 | -| `user +stats` 返回空或权限不足 | 无统计数据 | API 响应为空或无权限 | 从 `pr +list` 聚合 PR/Issue 数 | -| `user +trends` 返回空或权限不足 | 无趋势数据 | API 响应为空或无权限 | 从 PR 按日聚合判断趋势 | +| `user +heatmap` 不可用 | 命令不存在 | user 仅 `+info`/`+me` | 从 PR 时间戳推算活跃天数 | +| `user +stats` 不可用 | 命令不存在 | 同上 | 从 `pr +list` 聚合 PR/Issue 数 | +| `user +trends` 不可用 | 命令不存在 | 同上 | 从 PR 按日聚合判断趋势 | | Raw API 返回 HTML | `api GET` 返回 HTML | 非 JSON 响应 | 仅使用 Shortcut 命令 | | 项目 < 30 天 | PR 时间跨度 < 30 天 | 全部 PR 在近期 | 放宽分级标准,标注"早期阶段" | | 贡献者 ≤ 2 人 | `contributor_users_count` ≤ 2 | Bus Factor 极低 | 报告标注风险 + 提供吸引新人建议 | @@ -232,7 +230,7 @@ Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人 | CLI 版本 | 贡献者分析可用命令 | 缺失命令 | |----------|-------------------|----------| | 0.1.18 | `repo +info`, `pr +list`, `issue +list`, `user +info` | `repo +contributors`, `user +heatmap`, `user +stats`, `user +trends` | -| 当前版本 | `repo +info`, `pr +list`, `issue +list`, `user +info`, `user +heatmap`, `user +stats`, `user +trends` | `repo +contributors` | +| 未来版本 | 可能新增 `user +heatmap` 等 | — | 当 CLI 版本更新后,重新验证可用命令: ```bash diff --git a/skills/gitlink-contributor-insight/SKILL.md b/skills/gitlink-contributor-insight/SKILL.md index 14954ce..9ad7ba8 100644 --- a/skills/gitlink-contributor-insight/SKILL.md +++ b/skills/gitlink-contributor-insight/SKILL.md @@ -25,9 +25,9 @@ gitlink-cli 的命令集在持续演进中。以下命令**当前版本可能不 | 命令 | 状态 | 替代方案 | |------|------|----------| | `repo +contributors` | ❌ 不可用 | 从 `pr +list` 提取 `author_login` + `repo +info` 获取 `contributor_users_count` | -| `user +heatmap` | ✅ 可用 | 贡献热力图 | -| `user +stats` | ✅ 可用 | 用户聚合统计 | -| `user +trends` | ✅ 可用 | 用户项目趋势 | +| `user +heatmap` | ❌ 不可用 | 从 PR 时间戳手动推算活跃天数 | +| `user +stats` | ❌ 不可用 | 从 `pr +list` 统计 PR 数;Issue 数通过 `issue +list` 获取 | +| `user +trends` | ❌ 不可用 | 从 PR 时间分布手动判断趋势(上升/平稳/下降) | | `repo +info` | ✅ 可用 | — | | `pr +list` | ✅ 可用 | — | | `user +info` | ✅ 可用 | — | @@ -90,16 +90,11 @@ gitlink-cli issue +list --owner --repo --format json ```bash # 用户基本信息 gitlink-cli user +info --login --format json - -# 贡献热力图、聚合统计、项目趋势 -gitlink-cli user +heatmap --user --format json -gitlink-cli user +stats --user --format json -gitlink-cli user +trends --user --format json ``` 从 `user +info` 提取:`login`、`name`、`created_time`(注册时间)、`user_projects_count`、`user_org_count`、`user_identity`。 -从 `user +heatmap/+stats/+trends` 补充贡献频率、贡献产出和项目趋势。如果这些端点返回空或权限不足,再使用 PR/Issue 列表推算: +**如果 `user +heatmap/+stats/+trends` 可用**(未来版本),补充执行。当前版本用以下替代方案: | 维度 | 替代数据源 | 分析要点 | |------|----------|----------| @@ -228,9 +223,9 @@ gitlink-cli user +trends --user --format json | PR 贡献数据 | `pr +list` 全量 | ✅ 可靠 | | Issue 数据 | `issue +list` | ✅ 可靠 | | 用户信息 | `user +info` | ✅ 可靠 | -| 贡献热力图 | `user +heatmap` | ✅ 可靠 | -| 统计信息 | `user +stats` | ✅ 可靠 | -| 趋势数据 | `user +trends` | ✅ 可靠 | +| 贡献热力图 | 不可用(命令未实现) | ❌ 缺失 | +| 统计信息 | 不可用(命令未实现) | ❌ 缺失 | +| 趋势数据 | 不可用(命令未实现) | ❌ 缺失 | > **局限性**:本报告仅反映 GitLink 平台活动,不包括其他平台(GitHub、GitLab 等)的数据。 ``` @@ -242,7 +237,7 @@ gitlink-cli user +trends --user --format json | 场景 | 处理方式 | |------|----------| | `repo +contributors` 不可用(当前版本常态) | 从 `pr +list` 的 `author_login` 提取贡献者列表 | -| `user +heatmap` / `+stats` / `+trends` 返回空或权限不足 | 从 PR 时间戳推算活跃天数,PR 聚合得产出量,时间分布得趋势 | +| `user +heatmap` / `+stats` / `+trends` 不可用 | 从 PR 时间戳推算活跃天数,PR 聚合得产出量,时间分布得趋势 | | `pr +list` 返回空 | 标注"仓库暂无 PR 数据",仅展示 `repo +info` 基本信息 | | `user +info` 返回空 | 标注"用户信息不可用",仅展示 PR 统计 | | 贡献者 > 15 人 | 仅分析 PR 数最高的前 10 位,报告中注明"基于 Top 10 分析" | @@ -256,7 +251,7 @@ gitlink-cli user +trends --user --format json - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 - ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 -- ✅ **优先使用用户统计快捷命令**:`user +heatmap/+stats/+trends` 可直接提供贡献热力图、聚合统计和项目趋势;PR/Issue 列表用于补充仓库内贡献明细 +- ⚠️ **核心数据来源为 `pr +list`**:当前版本 gitlink-cli 中 `user +heatmap/+stats/+trends` 不可用,分析主要依赖 PR 列表数据 - ⚠️ **`repo +contributors` 不可用**:贡献者列表从 PR 作者提取,可能与实际 `contributor_users_count` 有差异(后者包含未提 PR 的参与者) - ⚠️ **数据仅反映 GitLink 平台活动**:不包括 GitHub 或其他平台的数据 - ℹ️ **参照样例**:[`EXAMPLES.md`](EXAMPLES.md) 包含手动执行和 Agent 调用两种场景的完整样例,[`examples/jiangtx-gitlink-cli.md`](examples/jiangtx-gitlink-cli.md) 包含原始命令输出数据 diff --git a/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md index 61a5ef1..57b4df5 100644 --- a/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md +++ b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md @@ -130,9 +130,7 @@ PR 详细列表: } ``` -### 5. 不可用的命令(gitlink-cli 0.1.18 历史记录) - -> 当前版本已新增 `user +heatmap`、`user +stats`、`user +trends`。下表仅记录本样例在 0.1.18 上的历史执行结果。 +### 5. 不可用的命令 | 命令 | 结果 | |------|------| diff --git a/skills/gitlink-insight/REFERENCE.md b/skills/gitlink-insight/REFERENCE.md index 4782a99..eb47435 100644 --- a/skills/gitlink-insight/REFERENCE.md +++ b/skills/gitlink-insight/REFERENCE.md @@ -87,7 +87,7 @@ gitlink-cli release +list --format json ### 仓库文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` | 字段 | 类型 | 说明 | @@ -100,7 +100,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' - ### 仓库语言统计 ```bash -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json ``` **返回示例:** `{ "Ruby": "90.2%", "JavaScript": "6.1%", "CSS": "3.7%" }` @@ -108,7 +108,7 @@ gitlink-cli api GET /:owner/:repo/languages --format json ### 贡献者列表 ```bash -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` | 字段 | 类型 | 说明 | @@ -121,13 +121,13 @@ gitlink-cli api GET /:owner/:repo/contributors --format json ### 仓库动态 ```bash -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ### 获取用户详情 ```bash -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json ``` | 字段 | 类型 | 说明 | diff --git a/skills/gitlink-insight/SKILL.md b/skills/gitlink-insight/SKILL.md index fdccafa..74d5ae4 100644 --- a/skills/gitlink-insight/SKILL.md +++ b/skills/gitlink-insight/SKILL.md @@ -49,13 +49,13 @@ gitlink-cli pr +list --state open --format json gitlink-cli pr +list --state merged --format json # 4. 获取仓库文件结构(检查文档、CI 配置) -gitlink-cli repo +tree --owner --repo --ref master --format json +gitlink-cli repo +files --query 'filepath=&ref=master' # 5. 获取语言统计 -gitlink-cli repo +languages --owner --repo --format json +gitlink-cli repo +languages --format json # 6. 获取贡献者列表 -gitlink-cli repo +contributors --owner --repo --format json +gitlink-cli repo +contributors --format json ``` ### 分析指标 @@ -131,7 +131,7 @@ gitlink-cli release +list --format json gitlink-cli issue +list --state open --format json # 5. 获取项目动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ### 输出格式 @@ -172,7 +172,7 @@ gitlink-cli api GET /:owner/:repo/activity --format json ```bash # 1. 获取贡献者列表 -gitlink-cli repo +contributors --owner --repo --format json +gitlink-cli repo +contributors --format json # 2. 获取每个贡献者的 PR # 通过 PR 列表按 author 过滤 @@ -249,29 +249,29 @@ gitlink-cli issue +list --state open --format json --- -## Shortcut 与 Raw API 参考 +## Raw API 参考 ```bash # 仓库信息 -gitlink-cli repo +info --owner --repo --format json +gitlink-cli repo +info --format json # 仓库语言统计 -gitlink-cli repo +languages --owner --repo --format json +gitlink-cli repo +languages --format json # 贡献者列表 -gitlink-cli repo +contributors --owner --repo --format json +gitlink-cli repo +contributors --format json -# 仓库动态(当前未覆盖为 shortcut,保留 Raw API) -gitlink-cli api GET /:owner/:repo/activity --format json +# 仓库动态 +gitlink-cli repo +activity --format json # 文件列表(检查文档/配置完整性) -gitlink-cli repo +tree --owner --repo --ref master --format json +gitlink-cli repo +files --query 'filepath=&ref=master' # 获取用户信息 -gitlink-cli user +info --login --format json +gitlink-cli user +info --login --format json # 用户贡献热力图 -gitlink-cli user +heatmap --user --format json +gitlink-cli user +heatmap --format json ``` ## 注意事项 diff --git a/skills/gitlink-insight/examples/sprint-report-workflow.md b/skills/gitlink-insight/examples/sprint-report-workflow.md index 74c917f..0aadbc3 100644 --- a/skills/gitlink-insight/examples/sprint-report-workflow.md +++ b/skills/gitlink-insight/examples/sprint-report-workflow.md @@ -28,7 +28,7 @@ gitlink-cli pr +list --state open --format json ## Step 3:获取项目动态 ```bash -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ## Step 4:生成周报 diff --git a/skills/gitlink-issue-triage/SKILL.md b/skills/gitlink-issue-triage/SKILL.md index 3d6a521..bdcaf70 100644 --- a/skills/gitlink-issue-triage/SKILL.md +++ b/skills/gitlink-issue-triage/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue-triage -version: 1.0.0 -description: "Issue 智能分拣:扫描未分类 Issue,AI 按语义/关键词自动分类打标签、推荐并分配责任人,再用 notification 验证通知到位,最后批量产出分拣报告。当用户需要治理堆积 Issue、自动打标签、分配负责人或检查通知状态时触发。" +version: 1.1.0 +description: "Issue 智能分拣:自动分析仓库 Issue 列表,按类型、紧急度、复杂度分类,生成分拣报告和维护建议。当用户需要整理 Issue、分类 Issue、Issue 分拣、Issue 优先级排序时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -11,274 +11,249 @@ metadata: # gitlink-issue-triage(Issue 智能分拣) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有写入/删除操作前(打标签、分配责任人、改状态),务必先确认用户意图。** +**CRITICAL — `issue +series-update` 为写操作,会批量修改 Issue 状态。执行前需确认用户意图。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 --- -## 工作流概览 +## 功能概述 -| 工作流 | 操作 | AI Agent 角色 | 写入 | -|--------|------|--------------|:----:| -| 工作流 1:自动分类打标签 | 扫描未分类 Issue → AI 判断类型 → 查/建标签 → 打标签 | 语义分类 + 标签创建 | 是 | -| 工作流 2:自动分配 + 通知 | 推荐责任人 → 分配 → 用 notification 验证通知 | 责任人推荐 | 是 | -| 工作流 3:批量分拣 + 报告 | 一次性处理所有未分类 Issue → 汇总报告 | 批处理 + 报告生成 | 是 | +对仓库的开放 Issue 进行全量扫描和智能分类,输出结构化的分拣报告: + +1. **类型分类** — 判断每个 Issue 是 Bug、功能请求、文档问题还是使用咨询 +2. **紧急度评估** — 根据关键词和优先级字段标注紧急程度 +3. **复杂度预估** — 根据描述详尽程度评估修复难度 +4. **活动日志分析** — 通过 `issue +journals` 查看 Issue 活动历史 +5. **行动建议** — 给出具体处理建议(立即修复/需讨论/可关闭/适合作入门任务) +6. **批量操作** — 支持通过 `issue +series-update` 批量更新 Issue 状态 --- -## 分类规则表 +## 工作流:Issue 全量分拣 -AI 读 Issue 标题 + 描述后按以下规则分类(关键词只是辅助,**最终以语义为准**,能识别关键词未覆盖的同义表述): +### Step 1:获取项目概览 -| Issue 关键词 / 语义 | 推荐标签 | 颜色 | 优先级 | -|---------------------|---------|------|:------:| -| bug / 错误 / 失败 / crash / 异常 / 报错 | bug | `#ee0701` | 🔴 高 | -| feature / 新增 / 建议 / 希望 / 能否支持 | enhancement | `#84b6eb` | 🔵 低 | -| 安全 / 漏洞 / 权限 / 泄露 / 注入 / XSS | security | `#b60205` | 🔴 高 | -| 性能 / 慢 / 卡顿 / 优化 / 内存 / OOM | performance | `#fbca04` | 🟡 中 | -| 文档 / README / 注释 / 示例 / 拼写 | documentation | `#0075ca` | 🔵 低 | -| question / 如何 / 怎么 / 请问 / ? | question | `#cc317c` | 🟡 中 | +```bash +gitlink-cli repo +info --owner --repo --format json +``` -**默认/兜底标签:** `triage`(`#ededed`,灰)—— 无法明确归类时打上,等人工复核。 +提取 `issues_count` 了解 Issue 池总量,`default_branch` 确认主分支。 -**分类决策原则:** -1. 安全类最高优先级(涉及漏洞即使同时是 bug 也归 security) -2. bug 优先于 enhancement(描述同时含两者时按 bug 处理) -3. 模糊的 feature/question 难以判断时归 question -4. 完全无法理解 → `triage` - ---- - -## 工作流 1:自动分类打标签 - -**触发场景:** "帮我自动分拣这个仓库的新 Issue" / "给所有没标签的 Issue 打标签" - -### Step 1:获取开放 Issue +### Step 2:获取全部开放 Issue ```bash gitlink-cli issue +list --owner --repo --state open --format json ``` -### Step 2:AI 筛选"未分类"Issue +> ⚠️ **已知问题**:`--state open` 过滤不准确,返回列表可能包含已关闭的 Issue。需在客户端按 `status_id` 二次过滤:保留 `status_id` = 1(新增)或 2(正在解决),排除 3(已解决)、5(关闭)。`status_id` = 0 纳入分析但标注"状态异常"。 -从返回结果中筛选出 `tags` 字段为空数组 `[]` 或缺失的 Issue(即没有任何标签)。已在 `gitlink-onboarding` 标过 `good first` 的 Issue 跳过,避免重复干预。 - -> 字段说明:`issue +list` 返回的 Issue 对象里,标签字段名是 **`tags`**(注意 `label +list` 用的是 `issue_tags`,两者不同)。每个 Issue 的 `number` 是网页 URL 显示的编号。 - -### Step 3:逐个读取详情用于分类 +如果返回数量 >20,追加分页参数获取全部: ```bash -gitlink-cli issue +view --owner --repo --number --format json +gitlink-cli issue +list --owner --repo --state open --format json --page 2 ``` -### Step 4:AI 按分类规则表判断类型 +### Step 3:逐条深入分析 -综合标题(`subject`)和描述(`description`)做语义分类,输出"类型 + 依据"。 - -### Step 5:查找或创建对应标签 +对过滤后的每条 Issue,获取详情: ```bash -# 先查现有标签,命中则复用 ID -gitlink-cli label +list --owner --repo --format json - -# 仅当现有标签里没有对应分类时才创建(注意:GitLink 标签名限 15 字符) -gitlink-cli label +create --owner --repo \ - --name "bug" --color "#ee0701" +gitlink-cli issue +view --owner --repo --number --format json ``` -> ⚠️ **优先复用现有标签(含中文同义词)**:GitLink 仓库通常自带中文标签——`缺陷`(bug) / `功能`(enhancement) / `文档`(documentation) / `疑问`(question) / `协助`(help wanted)。**先匹配这些再考虑新建英文标签**,避免一个仓库里同时存在 `bug` 和 `缺陷` 两套语义重复的标签。颜色建议沿用现有标签的色值,保持视觉一致。 +分析以下维度: -### Step 6:自动打标签 +| 维度 | 关注字段 | 分析要点 | +|------|----------|----------| +| 类型 | `subject`, `description` | 标题和描述中的关键词 | +| 紧急度 | `priority`, `subject` | 优先级字段 + 标题紧急信号 | +| 复杂度 | `description` 长度 | 描述的详细程度、是否有复现步骤 | +| 活跃度 | `comment_journals_count`, `updated_at` | 讨论热度和最后活跃时间 | +| 分配状态 | `assigners` | 是否已有人负责 | + +### Step 3.5:活动日志分析(v1.1 新增) + +对高优先级 Issue(urgent/high),获取活动日志以了解处理进展: ```bash -# --label 是"覆盖"语义:Issue 已有标签时必须把原 ID 一并传入 -gitlink-cli issue +update --owner --repo \ - --number --label [,<原标签id>...] +gitlink-cli issue +journals --owner --repo --number --format json ``` -### Step 7:输出分类报告 +从 `journals` 数组中提取: +- 最近一次状态变更时间和操作者 +- 最近一次评论时间和作者 +- 是否有 @提及等待回复 +- 是否有分配变更记录 + +> ⚠️ **控制调用量**:仅对 urgent/high 级别的 Issue 获取活动日志。日志数据可能较大,只提取关键时间节点。 + +### Step 4:分类规则 + +#### 4.1 类型分类(type) + +| 类型 | 匹配规则 | +|------|----------| +| **bug** | 标题/描述含 `bug`、`错误`、`失败`、`崩溃`、`异常`、`修复`、`fix`、`修复`、`报错`、`不工作`、`问题`(上下文为故障时) | +| **feature** | 标题/描述含 `feature`、`新增`、`添加`、`希望`、`建议`、`需要`、`支持`、`实现`,且非故障描述 | +| **docs** | 标题/描述含 `文档`、`doc`、`README`、`说明`、`教程`、`注释` | +| **question** | 标题/描述含 `如何`、`怎么`、`是否`、`能不能`、`请问`、`为什么`,且以问号结尾或明显为咨询语气 | +| **refactor** | 标题/描述含 `重构`、`refactor`、`优化结构`、`代码清理`、`技术债` | +| **ci** | 标题/描述含 `CI`、`CD`、`构建`、`部署`、`pipeline`、`自动化`、`测试环境` | +| **meta** | 维护者创建的元讨论帖、反馈收集帖、公告,无具体技术任务指向 | +| **other** | 不匹配以上任何类型时的兜底分类 | + +#### 4.2 紧急度评估(urgency) + +| 级别 | 判定条件 | +|------|----------| +| **urgent** | 标题含 `紧急`、`urgent`、`hotfix`、`生产`、`线上`、`崩溃`;或 `priority.name` = "紧急" | +| **high** | `priority.name` = "高";或标题含 `严重`、`阻塞`、`关键` | +| **normal** | 默认级别;`priority.name` = "正常" 或无优先级 | +| **low** | `priority.name` = "低";或标题含 `优化`、`nice to have`、`小建议` | + +#### 4.3 复杂度预估(complexity) + +| 级别 | 判定条件 | +|------|----------| +| **easy** | 描述简洁明确,有清晰复现步骤或单一功能点;`description` < 300 字且范围明确 | +| **medium** | 涉及多个文件/模块,需要一定背景了解;`description` 300~800 字,或虽有描述但需推断 | +| **hard** | 涉及架构变更、新子系统、跨模块重构;`description` > 800 字或非常模糊 | + +> **特殊情况**:`description` 仅含图片附件链接而无可读文字 → 视为"描述缺失",复杂度标记为 hard(因无法评估),建议标记为 discuss。 + +#### 4.4 行动建议(action) + +| 建议 | 判定条件 | +|------|----------| +| **fix-now** | bug + urgent/high | +| **investigate** | bug + normal/low,需先确认复现 | +| **implement** | feature + 描述清晰 + 范围明确 | +| **discuss** | 描述模糊、需求不清、或 question 类型 | +| **close-candidate** | 超过 90 天无更新、无评论、无分配 | +| **good-first-issue** | complexity=easy + 无人分配 + 范围明确 | + +### Step 5:生成分拣报告 + +将所有分析结果组织输出。 + +### Step 6:批量操作(v1.1 新增,可选,需确认) + +根据分拣结果,可批量更新 Issue 状态: + +```bash +gitlink-cli issue +series-update --owner --repo --ids --status closed --format json +``` + +> ⚠️ **写操作**:执行前需向用户展示将要操作的 Issue 列表,获得确认后再执行。 + +典型使用场景: +- 批量关闭 `close-candidate` 列表中的 Issue +- 批量将 `good-first-issue` 标记为 open(确保状态正确) + +--- + +## 输出模板 ```markdown -## 🏷️ Issue 分类报告 — / +# 📊 {{仓库名}} Issue 分拣报告 -📅 分拣时间: +> 分析时间:{{当前时间}} +> Issue 总数:{{total}},开放:{{open_count}},本次分析:{{analyzed_count}} 条 -| Issue | 标题(节选) | 分类 | 依据 | 标签 ID | -|-------|------------|:----:|------|:------:| -| #12 | 登录后偶发 500 报错 | bug | "500 报错"语义 | 382700 | -| #13 | 希望支持 webhook 自定义 header | enhancement | "希望支持" | 382701 | +--- -### 📊 汇总 -- 处理:2 个未分类 Issue -- bug × 1(🔴 高)|enhancement × 1(🔵 低) -- 兜底 triage:0 个 +## 总览 + +| 指标 | 数量 | +|------|------| +| Bug | {{bug_count}} | +| 功能请求 | {{feature_count}} | +| 文档 | {{docs_count}} | +| 咨询 | {{question_count}} | +| 元讨论 | {{meta_count}} | +| 其他 | {{other_count}} | +| **需立即处理** | {{urgent_count}} | +| **适合入门** | {{good_first_issue_count}} | + +--- + +## 🔴 需立即处理 + +> 如本段为空,输出:*当前无紧急 Issue,状态健康。* + +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| {{number}} | {{subject}} | bug | urgent | medium | {{last_journal_time}} | fix-now | | +| ... | ... | ... | ... | ... | ... | ... | ... | + +## 🟡 建议近期处理 + +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| ... | ... | bug/feature | high/normal | easy/medium | ... | investigate/implement | | + +## 🟢 可延迟 / 需讨论 + +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| ... | ... | question/feature | normal/low | medium/hard | ... | discuss | | + +## ⭐ 适合入门(Good First Issue) + +| # | 标题 | 类型 | 复杂度 | 推荐理由 | +|---|------|------|--------|----------| +| {{number}} | {{subject}} | bug/docs | easy | 范围明确,单文件修改 | + +## ⚠️ 候选关闭(90+ 天无活动) + +| # | 标题 | 最后更新 | 建议 | +|---|------|----------|------| +| {{number}} | {{subject}} | {{updated_at}} | 评论询问是否仍需要,如无回应可关闭 | + +--- + +## 📋 维护建议 + +1. **立即行动**:{{urgent_count}} 个紧急 Issue 需要优先处理 +2. **本周目标**:建议处理 {{suggested_this_week}} 个 Issue +3. **社区引导**:{{good_first_issue_count}} 个 Issue 适合标记为 good first issue +4. **清理计划**:{{close_candidate_count}} 个 Issue 长期无活动,建议批量关闭 +5. {{#if no_tags}}本仓库未使用 Issue 标签系统,建议建立标签体系{{/if}} +6. {{#if status_anomalies}}本批次有 {{status_anomaly_count}} 个 Issue 状态异常(status_id=0){{/if}} + +### 批量操作建议 + +> 如无适用操作,输出:*当前无需批量操作。* + +以下 Issue 建议批量关闭(已确认超 90 天无活动): +`gitlink-cli issue +series-update --owner --repo --ids {{close_ids}} --status closed` ``` --- -## 工作流 2:自动分配 + 通知 +## 异常场景处理 -**触发场景:** "帮我给这些 Issue 分配责任人,并通知他们" - -### Step 1:获取可分配的成员列表 - -```bash -gitlink-cli issue +assigners --owner --repo --format json -``` - -返回仓库协作成员(含 `id` 和 `login`),AI 据此推荐责任人。 - -> ⚠️ **个人仓库会返回空数组**(`"assigners": [], "total_count": 0`)。GitLink 的 `/issue_assigners` 只返回具有**显式项目角色**的成员(collaborator/manager 等),**不隐式包含 owner**。空数组不是命令失败,而是真实场景——此时按下方"列表为空"分支处理。 - -### Step 2:AI 推荐责任人 - -按 Issue 类型与成员专长做匹配(无成员画像时按公平轮询/按 Issue 类型分组): - -| Issue 类型 | 推荐策略 | -|-----------|---------| -| bug / security | 优先派给最近修过相关模块的成员 | -| documentation | 任意有空闲的成员 | -| question | 仓库 owner 或 maintainer | -| performance | 核心开发成员 | - -如可分配列表为空(个人仓库、无 collaborator),跳过分配并在报告中标注"无可分配成员"。 - -### Step 3:分配责任人(Raw API) - -> ⚠️ `issue +update` 当前不支持 `--assignee`(见下方"已知限制"),分配必须走 Raw API PATCH,并保留原 `subject`/`description`。 - -```bash -# Step 3a:先 GET 拿到当前 subject 和 description(避免被清空) -gitlink-cli issue +view --owner --repo --number --format json - -# Step 3b:PATCH 分配(assigned_to_id 用 assigners 返回的用户 id) -# ⚠️ Git Bash 用户必须加 MSYS_NO_PATHCONV=1 前缀,否则 / 开头路径会被 MSYS2 转成 -# Windows 路径(debug 实测:/v1/... 变成 /api/F:/Git/Git/v1/...),导致 404 -# 注:api 命令会自动补 .json 后缀,路径无需手动加(已实测确认) -MSYS_NO_PATHCONV=1 gitlink-cli api PATCH /v1///issues/ --body '{ - "subject": "<原 subject 原样回传>", - "description": "<原 description 原样回传>", - "assigned_to_id": -}' -``` - -### Step 4:用 notification 验证通知到位 - -GitLink 在分配责任人时会**自动**给被分配人发一条站内消息。读取该成员的通知列表确认: - -```bash -# ⚠️ --owner 必须填【当前登录账号】自己的 login,不能填被分配人的 -# GitLink 平台限制:notification 只允许查自己的消息,跨用户查询会 403 -gitlink-cli notification +list --owner <当前登录账号 login> --format json -``` - -在返回里查找 `source: ProjectIssue` 且 `notification_url` 含对应 Issue 编号的条目,确认通知已生成。 - -> ⚠️ **跨用户查询通知会被 403 拒绝**(实测:zhangqing23 查 ylly 的通知返回 `[403] 您没有权限进行该操作`)。所以**第三方无法代为验证**他人是否收到通知——这条限制在工作流 2 的报告中要如实告知用户:"已分配给 X,X 是否收到通知需 X 本人 `notification +list --owner X` 自查"。 - -### Step 5:输出分配结果 - -```markdown -## 👥 责任人分配报告 — / - -| Issue | 分类 | 责任人 | 通知状态 | -|-------|:----:|--------|:------:| -| #12 | bug | @zhangqing | ✅ 已通知 | -| #13 | enhancement | @ylly | ✅ 已通知 | - -> 责任人可在 GitLink 网页对应 Issue 页右侧"负责人"栏查看。 -``` - ---- - -## 工作流 3:批量分拣 + 报告 - -**触发场景:** "把仓库里所有没分类的 Issue 一次性处理掉" - -### Step 1:批量拉取未分类 Issue - -```bash -gitlink-cli issue +list --owner --repo --state open --format json -# AI 客户端过滤 tags 为空的 Issue -``` - -### Step 2:对每个 Issue 执行"工作流 1 + 工作流 2" - -依次分类打标签 → 推荐并分配责任人。**批量写入前先给用户一份预览清单**,得到确认后再批量执行。 - -### Step 3:生成分拣总报告 - -```markdown -## 📋 Issue 智能分拣总报告 — / - -📅 处理时间: -🎯 处理范围:所有开放且未分类的 Issue - -### 分类分布 -| 类型 | 数量 | 占比 | -|------|:----:|:----:| -| 🔴 bug | 3 | 30% | -| 🔴 security | 1 | 10% | -| 🟡 performance | 2 | 20% | -| 🔵 enhancement | 3 | 30% | -| 🔵 documentation | 1 | 10% | -| ⚪ triage(待人工) | 0 | 0% | -| **合计** | **10** | **100%** | - -### 责任人分配 -| 责任人 | 分到 | 涉及 Issue | -|--------|:----:|-----------| -| @zhangqing | 4 | #12 #15 #18 #20 | -| @ylly | 3 | #13 #14 #17 | -| 待分配 | 3 | #16 #19 #21(无可分配成员) | - -### ⚠️ 需人工跟进 -- #21 描述过于模糊 → 已打 `triage`,需 owner 复核 -- #19 涉及架构重构 → 已打 `enhancement` 但建议核心成员评估 - -### 🔗 网页验证 -- Issue 列表:https://gitlink.org.cn///issues -- 标签视图:https://gitlink.org.cn///issues/tags -``` - ---- - -## Raw API 参考 - -```bash -# 分配责任人(issue +update 当前不支持 --assignee,必须走 Raw API) -# ⚠️ Git Bash 加 MSYS_NO_PATHCONV=1 前缀(见注意事项);.json 由 api 自动补 -MSYS_NO_PATHCONV=1 gitlink-cli api PATCH /v1///issues/ --body '{ - "subject": "<原标题>", "description": "<原描述>", "assigned_to_id": -}' - -# 查询可分配成员 -gitlink-cli issue +assigners --owner --repo --format json - -# 查询某用户的通知(--owner 填该用户自己的 login) -gitlink-cli notification +list --owner --format json - -# 把通知标记为已读(如需) -gitlink-cli notification +read --owner --id -``` +| 场景 | 处理方式 | +|------|----------| +| 无开放 Issue | 输出 `repo +info` 概览后,恭喜维护者"Issue 池已清空" | +| Issue 数量 >50 | 优先分析最近 30 天更新的 Issue,其余标记为"待分批处理" | +| 全部 Issue 无标签/无优先级 | 分类完全依赖标题和描述关键词分析,并在报告末尾建议建立标签体系 | +| `description` 为空或仅含图片/附件链接 | 标注"描述缺失",类型仅根据标题判断,复杂度标为 hard,建议标记为 discuss | +| `status_id` = 0(未知) | 纳入分析但标注"状态异常" | +| `issue +journals` 返回空 | 标注"无活动日志",不阻塞分析 | --- ## 注意事项 -- **写操作前确认:** 打标签(`issue +update --label`)和分配责任人(`api PATCH`)会真实修改 Issue,批量执行前先给预览清单等用户确认。 -- **--label 是覆盖语义:** `issue +update --label ` 会替换原有标签。若 Issue 已有标签(例如 `good first`),必须把原标签 ID 一并传入(如 `--label 382660,382700`),否则原标签会丢失。 -- **标签颜色格式:** `label +create --color` 必须带 `#` 号(如 `#ee0701`)。 -- **标签名长度限制:** GitLink 标签名上限 **15 字符**。中文标签(如"文档")通常没问题,英文长名(如"enhancement" 11 字符 OK,"good first issue" 16 字符会被截断)需注意。 -- **`issue +update` 不支持 `--assignee`:** 当前 Shortcut 的 update 子命令仅支持 `--title/--body/--state/--label`。分配责任人需走 Raw API `PATCH /v1/:owner/:repo/issues/:n`,且必须带上原 `subject` 和 `description`(参考 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) API 注意事项:Issue 更新不带 subject/description 可能被清空)。 -- **PowerShell 跑 Raw API --body JSON 会被吞双引号:** Windows PowerShell 5 把含 `"` 的字符串传给原生 exe 时会 strip 引号,导致 `encoding/json` 解析失败(报错 `invalid character 's' looking for beginning of object key string`)。**改用 Git Bash 或 cmd.exe 跑同一条命令可正常通过**(bash 单引号原样保留 JSON)。 -- **Git Bash(MSYS2)会转换 `/` 开头的路径参数(PATCH 404 的真正原因):** 以 `/` 开头的 Raw API 路径(如 `/v1/owner/repo/issues/9`)会被 Git Bash 自动转成 Windows 路径(debug 实测:`/v1/...` 被改成 `/api/F:/Git/Git/v1/...`),请求 URL 错误、返回 404。**这是本机 PATCH 失败的唯一原因,与 .json 无关**(`api` 命令会自动补 `.json` 后缀,已用 `--debug` 实测确认:不带 `.json` 的请求最终 URL 仍是 `.../issues/9.json`)。**解决:命令前加 `MSYS_NO_PATHCONV=1`**(实测 `MSYS_NO_PATHCONV=1 gitlink-cli api PATCH /v1/.../issues/9 ...` 返回 `ok:true`);或路径用双斜杠 `//v1/...`。cmd.exe 无此路径转换问题(PowerShell 的坑是引号,见上一条)。 -- **`assigned_to_id` 用数字 ID:** 不是 login 字符串。从 `issue +assigners` 返回里取 `id` 字段。⚠️ **实测:个人仓库 `assigners` 为空时,用 owner user_id 兜底分配也不生效**——GitLink 校验 `assigned_to_id` 必须在 assigners 候选列表内,PATCH 虽返回 `ok:true` 但 `assigned_to` 仍为空。个人仓库需先 `member +add` 添加 collaborator 才能分配,否则跳过分配并在报告标注"无可分配成员"。 -- **notification +list 是自查询限定:** 该命令查 `/users//messages`,**GitLink 平台只允许用户查询自己的通知**,跨用户查询返回 `[403] 您没有权限进行该操作`(实测:zhangqing23 查 ylly 的通知被拒)。因此无法第三方代为验证通知到达,只能由责任人本人自查。 -- **分配会自动触发通知:** GitLink 平台在 `assigned_to_id` 变更时会自动给被分配人发站内消息,**无需也不存在** "send notification" 命令。`notification +list` 只用于**验证**通知已生成(且只能自验证)。 -- **`assigners` 字段两个位置:** Issue 对象里 `assigners` 是已分配人列表(数组),`issue +assigners` 命令返回的是**可分配的候选人**列表。两者不同,别混淆。 -- **`--number` 是网页编号:** 用 GitLink 网页 URL 中显示的编号(`/issues/`),不是数据库主键。 -- **不要和 `gitlink-onboarding` 冲突:** 已被 onboarding 标记为 `good first` 的 Issue 通常已分类,分拣时可跳过,避免覆盖标签。 -- **分类不是终审:** AI 分类有误判可能,对"模棱两可"或"高严重度"的 Issue 建议同时打 `triage` 让人工复核,或在报告中明确标注不确定项。 +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **`issue +view` 和 `+journals` 使用 `--number`(网页编号)**,非数据库 ID +- ✅ **本 Skill 以只读分析为主**,批量操作需确认后执行 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **`issue +list --state open` 过滤不准确**,必须客户端按 `status_id` 二次过滤 +- ⚠️ **`issue +journals` 仅对 urgent/high Issue 调用**,控制 API 调用量 +- ⚠️ **`issue +series-update` 为写操作**,需用户确认,使用逗号分隔的 Issue ID +- ⚠️ **分类规则是启发式的**,AI 应根据实际内容做判断,不要机械匹配关键词 +- ⚠️ **Issue 数量多时分批处理**,超过 50 条建议先按更新时间排序 diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 4933698..b64c8f2 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -88,10 +88,10 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ```bash # 获取 Issue 评论列表(使用 v1 API,按 issue number 查询) -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals +gitlink-cli issue +journals # 批量更新 Issue(仍使用旧版 API,需传数据库 ID) -gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}' +gitlink-cli issue +series-update --body '{"ids":[1,2,3],"status_id":"closed"}' ``` ## GitLink Issue 字段映射 diff --git a/skills/gitlink-notification-digest/EXAMPLES.md b/skills/gitlink-notification-digest/EXAMPLES.md index 5884aa1..7c52285 100644 --- a/skills/gitlink-notification-digest/EXAMPLES.md +++ b/skills/gitlink-notification-digest/EXAMPLES.md @@ -4,17 +4,21 @@ **日期**:2026-06-03 **用户**:lindiwen23 -**CLI 版本**:支持 `notification` shortcut 的 gitlink-cli +**CLI 版本**:gitlink-cli 0.1.18 ### 执行流程 ```bash +# Step 1: 获取用户名 +gitlink-cli auth status +# → Logged in as lindiwen23 + # Step 2: 获取未读通知(status=1) -gitlink-cli notification +list --status unread --limit 20 --format json +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json # → 7 条未读,unread_notification=7, unread_atme=0 # Step 3: 获取已读通知(用于趋势分析和回顾) -gitlink-cli notification +list --status read --limit 20 --format json +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json # → 21 条已读 # Step 4: 分类统计、生成摘要报告 @@ -27,8 +31,9 @@ gitlink-cli notification +list --status read --limit 20 --format json | 未读通知 | 7 条 | | @我未读 | 0 条 | | 总通知 | 28 条(7 未读 + 21 已读) | -| 推荐命令 | `gitlink-cli notification +list` | -| 标记已读 | `gitlink-cli notification +read --ids ... --dry-run/--yes` | +| 不存在命令 | `gitlink-cli notification`(整个子命令不存在) | +| 实际 API | `GET /api/users/{owner}/messages.json` | +| CLI Bug | `api` 路径以 `/` 开头会被解析为本地文件路径 | ### 原始 API 返回(未读 7 条) @@ -171,11 +176,12 @@ gitlink-cli notification +list --status read --limit 20 --format json ### 经验总结 -1. **优先使用 `notification` shortcut**:列表、标记已读、删除和发送 @ 消息均已有封装 -2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语,shortcut 已屏蔽路径细节 -3. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 -4. **标记已读是写操作**:必须先 `--dry-run`,用户确认后再 `--yes` -5. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 +1. **`gitlink-cli notification` 命令不存在**:GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API +2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语 +3. **CLI 路径 Bug**:`gitlink-cli api` 的 PATH 参数以 `/` 开头会被解析为本地文件路径,必须去掉前导 `/` +4. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 +5. **没有批量已读 API**:标记已读需逐条调用 `POST users/{owner}/messages/{id}/read` +6. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 --- @@ -188,17 +194,22 @@ gitlink-cli notification +list --status read --limit 20 --format json ``` 工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件 -工具调用 2: Bash → gitlink-cli notification +list --status unread --limit 20 --format json -工具调用 3: Bash → gitlink-cli notification +list --status read --limit 20 --format json -工具调用 4: Bash → gitlink-cli notification +list --limit 20 --format json +工具调用 2: Bash → gitlink-cli auth status ← 获取用户名 +工具调用 3: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "status=1&limit=20" --format json ← 获取未读 +工具调用 4: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "status=2&limit=20" --format json ← 获取已读(趋势分析) +工具调用 5: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "limit=20" --format json ← 获取全部(总计统计) ``` ### Agent 决策过程 -Agent **正确遵循了 skill v3.0.0 的工作流**: +Agent **正确遵循了 skill v2.0.0 的工作流**: 1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数 -2. 使用 `notification +list` 获取未读、已读、全部三类数据 +2. 用 `auth status` 获取当前用户 `lindiwen23` +3. 使用 Raw API(路径无前导 `/`)获取未读、已读、全部三类数据 4. 按 `source` 字段分类:`ProjectPullRequest` → P2,`ProjectPraised`/`ProjectMemberJoined` → P3 5. 按输出模板生成结构化报告,含所有七个章节 6. 主动询问是否需要标记 P3 通知为已读 @@ -257,9 +268,9 @@ Agent **正确遵循了 skill v3.0.0 的工作流**: ### 验证结论 -✅ skill v3.0.0 验证通过: -- Agent 正确使用了 `gitlink-cli notification +list` 获取消息列表 -- Agent 正确使用了 `gitlink-cli notification +read --dry-run` 预览标记已读操作 +✅ skill v2.0.0 验证通过: +- Agent 正确使用了 `gitlink-cli api` 而非不存在的 `gitlink-cli notification` +- Agent 路径没有以 `/` 开头,避开了 CLI 路径解析 Bug - Agent 按 `source` 枚举值正确分类,识别出 `PullReuqestAtme` 拼写异常 - Agent 正确区分了 P0/P1/P2/P3 优先级 - Agent 使用 `unread_notification`/`unread_atme` 顶层字段快速统计 @@ -272,22 +283,22 @@ Agent **正确遵循了 skill v3.0.0 的工作流**: | 场景 | 检测方式 | 处理 | |------|----------|------| -| `notification +list` 失败 | 查看错误信息 | 先确认已登录,再运行 `gitlink-cli notification +list --format json` | -| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--page 2` | -| 用户名不确定 | shortcut 自动解析当前用户失败 | 先执行 `gitlink-cli auth status` | +| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` | +| API 返回 HTML 而非 JSON | 响应以 `` 开头 | 去掉路径前导 `/` 重试 | +| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` | +| 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 | | 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" | --- ## 版本兼容性说明 -本 skill v3.0.0 基于新增的 `notification` shortcut 编写。关键变更: +本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更: | 版本 | `notification` 子命令 | 实际 API | 标记已读 | |------|----------------------|----------|----------| | v1.0.0 | `notification +list`(虚构) | 不存在 | `notification +read-all`(虚构) | | v2.0.0 | 无此子命令 | `GET /api/users/{owner}/messages.json` | `POST /api/users/{owner}/messages/{id}/read` | -| v3.0.0 | `notification +list` | 由 shortcut 封装 messages API | `notification +read --ids ... --dry-run/--yes` | 当 CLI 版本更新后,重新验证可用命令: ```bash diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index bb91e02..72cf9df 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -5,7 +5,6 @@ description: "通知摘要:汇总 GitLink 通知并按类型分类,生成通 metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli api --help" --- # gitlink-notification-digest(通知摘要) @@ -33,9 +32,17 @@ metadata: ## ⚠️ 关键注意事项 -### CLI Shortcut 优先 +### CLI 路径处理 Bug -通知摘要优先使用 `notification` shortcut,不再直接拼接 Raw API 路径。只有 shortcut 未覆盖的新接口才回退到 `gitlink-cli api`。 +**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。 + +```bash +# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/... +gitlink-cli api GET /users/me + +# ✅ 正确 — 去掉前导 / +gitlink-cli api GET "users/{owner}/messages.json" +``` ### 术语对照 @@ -47,33 +54,33 @@ GitLink 平台用「**消息**」(messages)而不是「通知」(notificat ### Step 1:获取通知列表 -使用 `notification +list` 获取消息列表: +使用 Raw API 调用 `/api/users/{owner}/messages.json`: ```bash # 获取未读通知(status=1 表示未读,2 表示已读) -gitlink-cli notification +list --status unread --limit 20 --format json +gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json # 获取全部通知(含已读) -gitlink-cli notification +list --limit 20 --format json +gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json # 分页获取 -gitlink-cli notification +list --status unread --page 2 --limit 20 --format json +gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&page=2&limit=20" --format json # 按类型过滤 # type=notification 系统消息(仓库动态、PR、Issue 等) # type=atme @我消息 -gitlink-cli notification +list --type atme --status unread --limit 20 --format json +gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json ``` **参数说明:** | 参数 | 位置 | 说明 | |------|------|------| -| `--user` | Flag | 目标用户登录名,不传时自动使用当前认证用户 | -| `--status` | Flag | `unread`/`1`=未读,`read`/`2`=已读,不传=全部 | -| `--type` | Flag | `notification`=系统消息,`atme`=@我消息,不传=全部 | -| `--page` | Flag | 页码(默认 1) | -| `--limit` | Flag | 每页条数(默认 20) | +| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) | +| `status` | Query | 1=未读,2=已读,不传=全部 | +| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 | +| `page` | Query | 页码(默认 1) | +| `limit` | Query | 每页条数(默认 20) | **响应结构:** @@ -186,18 +193,17 @@ gitlink-cli notification +list --type atme --status unread --limit 20 --format j ### Step 4:标记已读(可选,需确认) ```bash -# 预览标记指定消息为已读 -gitlink-cli notification +read --ids ,, --dry-run --format json +# 标记单条已读 +gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json -# 确认执行 -gitlink-cli notification +read --ids ,, --yes --format json - -# 预览将全部未读系统通知标记为已读 -gitlink-cli notification +read --type notification --all-unread --dry-run --format json +# 批量标记已读 — 逐条调用,GitLink 暂无批量已读 API +for id in ; do + gitlink-cli api POST "users/{owner}/messages/$id/read" --format json +done ``` > ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。 -> ⚠️ **必须先 dry-run,再由用户确认后加 `--yes` 执行。** +> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。 --- @@ -277,7 +283,7 @@ gitlink-cli notification +read --type notification --all-unread --dry-run --form - 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 如需标记 P3 通知为已读,我可以逐条执行: -`gitlink-cli notification +read --ids --dry-run` +`gitlink-cli api POST "users/{owner}/messages/{id}/read"` ``` --- @@ -288,8 +294,9 @@ gitlink-cli notification +read --type notification --all-unread --dry-run --form |------|----------| | 无未读通知 | 输出"🎉 所有通知已处理完毕" | | 通知数量 > 50 | 分页获取(page 1/2/3),优先分析最近 50 条 | -| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--page 2` 获取 | -| 用户名不确定 | `notification +list` 会自动读取当前认证用户;失败时先执行 `gitlink-cli auth status` | +| API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 | +| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 | +| 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 | --- @@ -298,6 +305,7 @@ gitlink-cli notification +read --type notification --all-unread --dry-run --form - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **标记已读为写操作**,执行前必须确认用户意图 - ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读 +- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**(CLI Bug) - ⚠️ **GitLink 用「消息(messages)」而非「通知(notifications)」** - ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配 -- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2` +- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"` diff --git a/skills/gitlink-org/SKILL.md b/skills/gitlink-org/SKILL.md index 0b3a7ee..d1943cc 100644 --- a/skills/gitlink-org/SKILL.md +++ b/skills/gitlink-org/SKILL.md @@ -18,15 +18,12 @@ metadata: ## Shortcuts -| Shortcut | 说明 | 需要认证 | -|----------|------|----------| -| `org +list` | 组织列表 | 否 | -| `org +info` | 组织详情 | 否 | -| `org +members` | 成员列表 | 否 | -| `org +create` | 创建组织 | 是 | -| `org +teams` | 团队列表 | 否 | -| `org +create-team` | 创建团队 | 是 | -| `org +remove-member` | 移除成员 | 是 | +| Shortcut | 说明 | +|----------|------| +| `org +list` | 组织列表 | +| `org +info` | 组织详情 | +| `org +members` | 成员列表 | +| `org +create` | 创建组织 | ## 使用示例 @@ -35,14 +32,15 @@ gitlink-cli org +list gitlink-cli org +info --id Gitlink gitlink-cli org +members --id Gitlink gitlink-cli org +create --name my-org --description "我的组织" - -# 管理团队 -gitlink-cli org +teams --id 12345 -gitlink-cli org +create-team --id 12345 --name dev-team -gitlink-cli org +remove-member --id 12345 --uid 67890 ``` -## 注意事项 +## Raw API 补充 -- `org +create` 创建的组织默认为公开 -- 团队管理和成员移除需组织 owner 权限 +```bash +# 组织团队管理 +gitlink-cli org +teams +gitlink-cli org +create-team --body '{"name":"dev-team"}' + +# 移除成员 +gitlink-cli org +remove-user +``` diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index fc5c98b..d56159a 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -106,7 +106,7 @@ gitlink-cli pr +create --owner TargetOrg --repo target-repo \ gitlink-cli branch +create --name feature-branch --from master # 2. 在分支上创建/修改文件(content 必须 base64 编码) -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "new-file.md", "content": "", "branch": "feature-branch", @@ -121,31 +121,31 @@ gitlink-cli pr +create --title "feat: 新功能" --head feature-branch --base ma ```bash # 创建文件(content 必须 base64 编码) -gitlink-cli api POST /:owner/:repo/create_file --body '{"filepath":"file.md","content":"","branch":"dev","message":"add file"}' +gitlink-cli repo +create-file --body '{"filepath":"file.md","content":"","branch":"dev","message":"add file"}' # 更新文件(需要先通过 sub_entries 获取文件 SHA) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=file.md&ref=dev' +gitlink-cli repo +files --query 'filepath=file.md&ref=dev' # 从 entries.sha 获取 SHA,然后: -gitlink-cli api PUT /:owner/:repo/update_file --body '{"filepath":"file.md","content":"","sha":"","branch":"dev","message":"update file"}' +gitlink-cli repo +update-file --body '{"filepath":"file.md","content":"","sha":"","branch":"dev","message":"update file"}' # 检查是否可合并 -gitlink-cli api POST /:owner/:repo/pulls/check_can_merge --body '{"head":"dev","base":"main"}' +gitlink-cli pr +check-merge --body '{"head":"dev","base":"main"}' # 创建 Review -gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM","status":"approved"}' +gitlink-cli pr +review --body '{"content":"LGTM","status":"approved"}' # 查看 Review 列表(支持 status 过滤) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews?status=approved +gitlink-cli pr +reviews +gitlink-cli pr +reviews?status=approved # 获取可用分支 -gitlink-cli api GET /:owner/:repo/pulls/get_branches +gitlink-cli pr +branches # 查看 PR patchset/version 列表(v1 API) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions +gitlink-cli pr +versions # 查看指定 patchset/version diff(可通过 filepath 过滤文件) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff +gitlink-cli pr +versions/:version_id/diff ``` ## 注意事项 diff --git a/skills/gitlink-pr/references/gitlink-pr-create.md b/skills/gitlink-pr/references/gitlink-pr-create.md index bde418a..beea3c0 100644 --- a/skills/gitlink-pr/references/gitlink-pr-create.md +++ b/skills/gitlink-pr/references/gitlink-pr-create.md @@ -53,7 +53,7 @@ gitlink-cli branch +create --name feature-branch --from master CONTENT=$(echo -n "文件内容" | base64) # 通过 Raw API 创建文件 -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "path/to/new-file.md", "content": "'$CONTENT'", "branch": "feature-branch", @@ -73,12 +73,12 @@ gitlink-cli pr +create --title "feat: 新功能" --head feature-branch --base ma ```bash # Step 2a: 获取文件 SHA -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=path/to/file.md&ref=feature-branch' +gitlink-cli repo +files --query 'filepath=path/to/file.md&ref=feature-branch' # 从返回的 entries.sha 获取 SHA 值 # Step 2b: 更新文件(content 必须 base64 编码) CONTENT=$(echo -n "更新后的内容" | base64) -gitlink-cli api PUT /:owner/:repo/update_file --body '{ +gitlink-cli repo +update-file --body '{ "filepath": "path/to/file.md", "content": "'$CONTENT'", "sha": "<从 sub_entries 获取的 sha>", diff --git a/skills/gitlink-release-auto/SKILL.md b/skills/gitlink-release-auto/SKILL.md index 6200905..ea4914d 100644 --- a/skills/gitlink-release-auto/SKILL.md +++ b/skills/gitlink-release-auto/SKILL.md @@ -49,7 +49,7 @@ gitlink-cli release +list --owner --repo --format json # 取 data.releases[0].tag_name 作为当前版本 # 获取标签列表 -gitlink-cli api GET /:owner/:repo/tags --format json +gitlink-cli repo +tags --format json ``` ### 步骤 2:获取自上次发版以来的提交 @@ -154,7 +154,7 @@ gitlink-cli pr +list --state merged --owner --repo --format json gitlink-cli release +list --owner --repo --format json # Step 2:获取变更内容(提交历史) -gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=30&ref=master' --format json +gitlink-cli repo +commits --query 'page=1&limit=30&ref=master' --format json # Step 3:生成 Release Notes(AI 分析提交后组织内容) diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 3e10c9a..10b3ceb 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-repo version: 1.0.0 -description: "仓库管理:创建、查看、Fork、删除仓库,查看 README、语言统计、贡献者、关注者,并执行关注/点赞等互动操作。当用户需要操作或分析 GitLink 仓库时触发。" +description: "仓库管理:创建、查看、Fork、删除仓库,查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,32 +22,10 @@ metadata: |----------|------|----------| | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | -| `repo +readme` | README 内容 | 否(公开项目) | -| `repo +tree` | 仓库文件树 | 否(公开项目) | -| `repo +files` | 搜索仓库文件 | 否(公开项目) | -| `repo +commits` | 提交历史列表 | 否(公开项目) | -| `repo +commit-files` | 单个提交的变更文件 | 否(公开项目) | -| `repo +commit-diff` | 单个提交的 diff | 否(公开项目) | -| `repo +tags` | 仓库标签列表 | 否(公开项目) | -| `repo +tag` | 仓库标签详情 | 否(公开项目) | -| `repo +languages` | 仓库语言统计 | 否(公开项目) | -| `repo +contributors` | 仓库贡献者列表 | 否(公开项目) | -| `repo +contributor-stats` | 贡献者代码行统计 | 否(公开项目) | -| `repo +code-stats` | 仓库代码统计 | 否(公开项目) | -| `repo +watchers` | 关注者列表 | 否(公开项目) | -| `repo +stargazers` | 点赞者列表 | 否(公开项目) | -| `repo +follow` | 关注仓库 | 是 | -| `repo +unfollow` | 取消关注仓库 | 是 | -| `repo +like` | 点赞仓库 | 是 | -| `repo +unlike` | 取消点赞仓库 | 是 | -| `repo +delete-tag` | 删除仓库标签,默认建议先 dry-run | 是 | -| `repo +batch-commit` | 多文件批量提交,默认建议先 dry-run | 是 | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | | `repo +delete` | 删除仓库 | 是 | -> 提交记录、标签列表、原始文件内容等暂未封装 Shortcut,可通过 Raw API 访问(见下方「Raw API 补充」)。 - ## 使用示例 ```bash @@ -61,49 +39,6 @@ gitlink-cli repo +info # 列出用户的仓库 gitlink-cli repo +list --user zhangsan -# 查看文件树、语言占比和贡献者 -gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master -gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main -gitlink-cli repo +languages --owner Gitlink --repo forgeplus -gitlink-cli repo +contributors --owner Gitlink --repo forgeplus - -# 搜索文件和分析提交历史 -gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref master -gitlink-cli repo +commits --owner Gitlink --repo forgeplus --ref master --limit 20 -gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus --sha -gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus --sha --file src/main.go -gitlink-cli repo +commit-diff --owner Gitlink --repo forgeplus --sha - -# 查看标签和标签详情 -gitlink-cli repo +tags --owner Gitlink --repo forgeplus --name v1 --only-name true -gitlink-cli repo +tag --owner Gitlink --repo forgeplus --name v1.0.0 -gitlink-cli repo +delete-tag --owner Gitlink --repo forgeplus --name v1.0.0 --dry-run - -# 查看代码统计 -gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1 -gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master - -# 查看社区关注数据 -gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 -gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 - -# 预览并执行仓库互动操作 -gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run -gitlink-cli repo +follow --owner Gitlink --repo forgeplus -gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 -gitlink-cli repo +like --owner Gitlink --repo forgeplus -gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 - -# 预览多文件提交;真实写入前必须确认用户意图 -gitlink-cli repo +batch-commit --owner me --repo proj \ - --branch master --message "docs: update guide" \ - --files 'update:README.md:# Updated;create:docs/demo.md:# Demo' \ - --dry-run -gitlink-cli repo +batch-commit --owner me --repo proj \ - --branch master --message "docs: update guide" \ - --files 'update:README.md:# Updated;delete:old.md' \ - --yes - # 创建仓库 gitlink-cli repo +create --name my-project --description "项目描述" @@ -112,41 +47,33 @@ gitlink-cli repo +fork --owner Gitlink --repo forgeplus # 删除仓库(⚠️ 危险操作) gitlink-cli repo +delete --owner myuser --repo old-project - -# 查看 README -gitlink-cli repo +readme --owner Gitlink --repo forgeplus - -# 查看贡献者 -gitlink-cli repo +contributors --owner Gitlink --repo forgeplus - -# 查看语言统计 -gitlink-cli repo +languages --owner Gitlink --repo forgeplus ``` ## Raw API 补充 -提交记录、标签列表、原始文件内容等操作暂未封装为 Shortcut,可直接用 Raw API: +Shortcuts 未覆盖的仓库操作可用 Raw API: ```bash -# 获取标签列表 -gitlink-cli api GET /:owner/:repo/tags +# 获取 README +gitlink-cli repo +readme -# 获取文件内容(按 ref 指定分支/标签) -gitlink-cli api GET /:owner/:repo/raw/main/README.md -gitlink-cli api GET /:owner/:repo/raw/develop/src/main.go +# 获取贡献者列表 +gitlink-cli repo +contributors + +# 获取语言统计 +gitlink-cli repo +languages + +# 获取提交列表 +gitlink-cli repo +commits --query 'page=1&limit=20' + +# 获取标签列表 +gitlink-cli repo +tags + +# 获取文件内容 +gitlink-cli repo +raw --ref=main/README.md ``` ## 注意事项 - `repo +delete` 是不可逆操作,执行前必须确认用户意图 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库 -- `repo +delete-tag` 会删除远端标签,Agent 必须先运行 `--dry-run` 并获得用户明确确认,再加 `--yes` -- `repo +batch-commit` 会修改仓库内容,Agent 必须先运行 `--dry-run` 并向用户展示 payload,再在用户明确确认后加 `--yes` -- `repo +batch-commit --files` 使用 `action:path[:content]`,多个操作用英文分号分隔;`delete` 不需要 content,`create/update` 需要 content - -## 参考文档 - -- [`references/gitlink-repo-tree.md`](references/gitlink-repo-tree.md) -- [`references/gitlink-repo-code-history.md`](references/gitlink-repo-code-history.md) -- [`references/gitlink-repo-tags.md`](references/gitlink-repo-tags.md) -- [`references/gitlink-repo-batch-commit.md`](references/gitlink-repo-batch-commit.md) diff --git a/skills/gitlink-research-tracker/SKILL.md b/skills/gitlink-research-tracker/SKILL.md index ae4e222..385197d 100644 --- a/skills/gitlink-research-tracker/SKILL.md +++ b/skills/gitlink-research-tracker/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-research-tracker -version: 1.0.0 +version: 1.1.0 description: "技术评估与调研报告:对技术项目进行多维度评估(社区活跃度、成熟度评分、技术趋势),生成含选型建议的结构化调研报告。当用户需要做技术评估、生成调研报告、科研选题分析、竞品对比研究时触发。" metadata: requires: @@ -22,7 +22,7 @@ metadata: 面向科研场景的技术调研工具,帮助研究者快速了解 GitLink 平台上的技术格局: -1. **多关键词搜索** — 将研究主题拆解为多个关键词,全面覆盖相关项目 +1. **多关键词搜索** — 仓库搜索 + 代码搜索 + Issue 搜索,三维覆盖 2. **项目深度评估** — 从活跃度、社区规模、代码产出等维度评估项目健康度 3. **横向对比** — 对比同类项目的核心指标,识别领先者和潜力项目 4. **趋势洞察** — 基于更新时间、贡献者增长、版本发布频率等推断技术趋势 @@ -45,9 +45,9 @@ metadata: > **原则**:关键词应覆盖中英文、缩写全称、技术术语和行业叫法。每个关键词独立搜索。 -### Step 2:多关键词搜索 +### Step 2:多维度搜索(v1.1 扩展:三维搜索) -对每个关键词执行搜索: +#### 2a. 仓库搜索 ```bash gitlink-cli search +repos -k <关键词> --format json @@ -57,13 +57,43 @@ gitlink-cli search +repos -k <关键词> --format json | SKILL 中用到的概念 | 实际字段来源 | 说明 | |-------------------|-------------|------| -| owner/repo 标识 | `author.login` + `/` + `identifier` | 搜索结果**没有** `full_name`,需手动拼接。`identifier` 是仓库的唯一标识符 | +| owner/repo 标识 | `author.login` + `/` + `identifier` | 搜索结果**没有** `full_name`,需手动拼接 | | 项目描述 | `description` | 直接可用 | -| 关注度 | `praises_count` | 搜索结果中叫 `praises_count`,**不是** `stars`。`watchers_count` 仅在 `repo +info` 中返回 | +| 关注度 | `praises_count` | 搜索结果中叫 `praises_count`,**不是** `stars` | | Fork 数 | `forked_count` | 搜索结果中叫 `forked_count`,**不是** `forks_count` | | 编程语言 | `language.name` | `language` 是嵌套对象 `{id, name}`,需取 `.name`。可能为 `null` | -| 更新时间 | `last_update_time`(Unix 时间戳)或 `full_last_update_time`(ISO 8601 字符串) | 搜索结果中**没有** `updated_at` | -| 是否镜像 | `mirror` | 仅在 `repo +info` 返回。GitLink 上大量仓库是 GitHub 镜像,需特别标注 | +| 更新时间 | `last_update_time` 或 `full_last_update_time` | 搜索结果中**没有** `updated_at` | +| 是否镜像 | `mirror` | 仅在 `repo +info` 返回 | + +#### 2b. 代码搜索(v1.1 新增) + +对技术关键词搜索代码引用,了解技术在实际项目中的使用情况: + +```bash +gitlink-cli search +code -k <关键词> --format json +``` + +从结果中提取: +- 匹配到的文件路径和仓库 +- 代码片段预览 +- 判断:哪些项目**实际使用了**该技术(而非仅描述中提到) + +> 代码搜索结果用于辅助判断"代码活跃度"——有大量代码匹配的项目说明该技术在实际开发中活跃使用。 + +#### 2c. Issue 搜索(v1.1 新增) + +搜索与主题相关的 Issue 讨论,了解技术痛点和需求: + +```bash +gitlink-cli search +issues -k <关键词> --format json +``` + +从结果中提取: +- 高频讨论主题 +- 常见技术痛点和需求 +- 社区对某个技术的关注焦点 + +> ⚠️ **控制搜索量**:代码搜索和 Issue 搜索仅针对 2-3 个核心关键词执行,不是全部关键词。避免 API 调用过多。 **去重规则**:用 `author.login/identifier` 作为唯一标识。同一仓库出现在多个关键词结果中时,只保留一次,标注匹配了哪些关键词。 @@ -87,6 +117,7 @@ gitlink-cli repo +info --owner --repo --format json | **代码规模** | `size` | 粗略判断项目复杂度 | | **开放性** | `forked_count` | fork 数反映二次开发热度 | | **PR 活跃度** | `pull_requests_count` | 反映代码贡献频率 | +| **代码活跃度**(v1.1) | `search +code` 命中量 | 反映技术在实际代码中的使用程度 | 可选补充(如有需要): @@ -98,33 +129,29 @@ gitlink-cli issue +list --owner --repo --state open --format json gitlink-cli release +list --owner --repo --format json ``` -> ⚠️ **控制分析数量**:深度评估仅对最有价值的 5~8 个项目执行(优先匹配多关键词、watchers 多、updated_at 最近的项目),避免过多 API 调用。 +> ⚠️ **控制分析数量**:深度评估仅对最有价值的 5~8 个项目执行,避免过多 API 调用。 ### Step 4:横向对比与趋势分析 #### 4.1 项目分类与镜像识别 -在评分之前,先通过 `repo +info` 的 `mirror` 字段区分项目类型: - | 类型 | 判定 | 处理 | |------|------|------| -| **镜像仓库** | `mirror: true` | 标注 `[镜像]`。GitLink 上的 `contributor_users_count`/`watchers_count` 等指标均为 0,不代表真实社区活跃度。评分仅作参考 | +| **镜像仓库** | `mirror: true` | 标注 `[镜像]`。评分仅作参考 | | **原创仓库** | `mirror: false` 且 `forked_from_project_id: null` | 正常评分 | -| **Fork 仓库** | `forked_from_project_id` 非 null | 标注 `[Fork]`,评分反映的是 Fork 后的独立开发情况 | +| **Fork 仓库** | `forked_from_project_id` 非 null | 标注 `[Fork]` | -#### 4.2 项目成熟度评分 - -对每个深度评估的项目,按以下标准打分(满分 25): +#### 4.2 项目成熟度评分(满分 25) | 维度 | 权重 | 评分标准 | |------|------|----------| | 社区规模 | 5 | contributor_users_count: >20=5, >10=4, >5=3, >2=2, ≤2=1 | -| 关注度 | 5 | repo +info 的 watchers_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | -| 研发节奏 | 5 | version_releases_count: >10=5, >5=4, >1=3, 0=2。**镜像仓库此项固定给 1**(镜像通常不通过 GitLink 发版)。注意 GitLink 平台 Release 功能使用率低,即使原创仓库 release=0 也建议给 2 而非 1 | -| 开发活跃 | 5 | 最近 30 天有更新=5, 60 天=4, 90 天=3, 180 天=2, >180 天=1。(基于 `repo +info` 的更新时间或搜索结果中的 `last_update_time`) | +| 关注度 | 5 | watchers_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | +| 研发节奏 | 5 | version_releases_count: >10=5, >5=4, >1=3, 0=2。镜像仓库固定给 1 | +| 开发活跃 | 5 | 最近 30 天有更新=5, 60 天=4, 90 天=3, 180 天=2, >180 天=1 | | 开放性 | 5 | forked_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | -> **镜像修正**:镜像仓库的社区规模、关注度、开放性三项在 GitLink 上均为 0,应标注"数据为 GitLink 平台内数据,不代表项目在原始平台(GitHub)的真实影响力",不参与排名比较。 +> **镜像修正**:镜像仓库评分仅作参考,不参与排名比较。 #### 4.3 技术趋势推断 @@ -132,6 +159,7 @@ gitlink-cli release +list --owner --repo --format json - **成熟信号**:大量 watcher + 稳定 Release 节奏 + 大社区 → 技术趋于成熟 - **衰退信号**:超过 180 天无更新 + 少量 contributor + 无新 Release → 可能已不活跃 - **新兴信号**:小社区 + 快速迭代 + 最新更新时间近 → 可能是新兴项目 +- **代码证据**(v1.1):`search +code` 命中量增长 → 技术采纳度上升 ### Step 5:生成技术调研报告 @@ -144,7 +172,8 @@ gitlink-cli release +list --owner --repo --format json > 调研时间:{{当前时间}} > 搜索关键词:{{keyword_list}} -> 搜索命中:{{total_hits}} 个仓库,去重后 {{unique_count}} 个,深度分析 {{deep_analysis_count}} 个 +> 搜索维度:仓库搜索 {{repo_hits}} + 代码搜索 {{code_hits}} + Issue 搜索 {{issue_hits}} +> 去重后 {{unique_count}} 个项目,深度分析 {{deep_analysis_count}} 个 --- @@ -157,15 +186,15 @@ gitlink-cli release +list --owner --repo --format json | 平均社区规模 | {{avg_contributors}} 人 | | 近 30 天活跃项目 | {{active_30d_count}}({{active_30d_pct}}%) | | 高成熟度项目(≥20分) | {{high_maturity_count}} | +| 代码引用量 | {{code_search_hits}} 次命中(反映技术采纳度) | --- ## 二、项目成熟度排行榜 -| 排名 | 项目 | 类型 | 评分 | 语言 | Watch | 贡献者 | Release | Fork | 关键词匹配 | -|------|------|------|------|------|-------|--------|---------|------|------------| -| 1 | {{full_name}} {{#if mirror}}[镜像]{{/if}} | {{原创/镜像/Fork}} | {{score}}/25 | {{language}} | {{watchers}} | {{contributors}} | {{releases}} | {{forks}} | {{matched_keywords}} | -| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | +| 排名 | 项目 | 类型 | 评分 | 语言 | Watch | 贡献者 | 代码引用 | Fork | 关键词匹配 | +|------|------|------|------|------|-------|--------|----------|------|------------| +| 1 | {{full_name}} | {{原创/镜像/Fork}} | {{score}}/25 | {{language}} | {{watchers}} | {{contributors}} | {{code_refs}} | {{forks}} | {{matched_keywords}} | --- @@ -188,18 +217,13 @@ gitlink-cli release +list --owner --repo --format json --- -### 🥈 {{项目名}}({{score}}/25) - -(同上格式) - ---- - ## 四、技术趋势洞察 -1. **热点方向**:{{当前最热的技术方向,基于项目分布推断}} -2. **新兴项目**:{{列出 1~3 个"新兴信号"明显的项目}} -3. **成熟生态**:{{列出 1~2 个"成熟信号"明显的项目,适合作为技术选型参考}} -4. **风险提示**:{{列出 1~2 个"衰退信号"项目或值得关注的生态空白}} +1. **热点方向**:{{当前最热的技术方向}} +2. **新兴项目**:{{1~3 个"新兴信号"明显的项目}} +3. **成熟生态**:{{1~2 个"成熟信号"明显的项目}} +4. **社区讨论焦点**(v1.1):基于 `search +issues` 的技术痛点分析 +5. **风险提示**:{{1~2 个"衰退信号"项目或生态空白}} --- @@ -223,6 +247,7 @@ gitlink-cli release +list --owner --repo --format json ## 六、数据来源 所有数据通过 `gitlink-cli` 从 GitLink 平台实时获取,每个项目均已通过 `repo +info` 验证。 +搜索维度:`search +repos`(仓库)、`search +code`(代码)、`search +issues`(Issue) ``` --- @@ -231,13 +256,14 @@ gitlink-cli release +list --owner --repo --format json | 场景 | 处理方式 | |------|----------| -| 关键词无搜索结果 | 尝试近义词或更宽泛的关键词重试,仍无结果则标注"该方向暂无相关项目" | -| 搜索返回大量结果(>50) | `search +repos` 无分页参数,实际返回约 20 条/关键词。合并后按 `praises_count` 降序取前 20 | -| 某项目 `repo +info` 返回 404 | 该项目可能为私有或已删除,从列表中移除 | -| `repo +info` 网络超时/TLS 错误 | 等待 5 秒后重试一次。仍失败则标注"网络请求失败",跳过该项目继续分析其余 | -| 大量搜索结果来自镜像仓库 | 优先分析 `mirror: false` 的原创项目。镜像项目保留但标注,评分仅作参考 | -| 所有项目评分均 <15 | 说明该领域尚未形成成熟生态,调整报告语气为"早期探索阶段" | -| 用户未提供具体关键词 | 引导用户明确研究主题,提供几个示例关键词供选择 | +| 关键词无搜索结果 | 尝试近义词重试,仍无结果则标注"该方向暂无相关项目" | +| 搜索返回大量结果(>50) | 合并后按 `praises_count` 降序取前 20 | +| 某项目 `repo +info` 返回 404 | 从列表中移除 | +| `repo +info` 网络超时/TLS 错误 | 等待 5 秒后重试一次,仍失败则跳过 | +| 大量搜索结果来自镜像仓库 | 优先分析 `mirror: false` 的原创项目 | +| 所有项目评分均 <15 | 调整报告语气为"早期探索阶段" | +| `search +code` 返回空 | 标注"代码搜索无命中",不阻塞分析 | +| `search +issues` 返回空 | 标注"Issue 搜索无命中",不阻塞分析 | | `language` 字段为 `null` | 标注为"未知" | --- @@ -246,11 +272,11 @@ gitlink-cli release +list --owner --repo --format json - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 -- ✅ **搜索关键词建议中英文各覆盖**,提高命中率 +- ✅ **搜索关键词建议中英文各覆盖** - ✅ **深度评估控制在 5~8 个项目**,避免调用过多 API -- ⚠️ **`search +repos` 和 `repo +info` 字段名不同**:搜索结果用 `praises_count`/`forked_count`/`author.login+identifier`,`repo +info` 才有 `watchers_count`/`full_name`/`mirror`。详见 Step 2 字段映射表 -- ⚠️ **`repo +info` 并发请求可能触发 TLS 超时**,失败时等 5 秒重试一次,不要放弃 -- ⚠️ **GitLink 平台镜像仓库比例高**,镜像仓库的社区数据为 0,不代表项目真实影响力。在报告中标注 `[镜像]` 并单独说明 -- ⚠️ **GitLink Release 功能使用率低**,大部分项目 `version_releases_count`=0。评分时 Release 维度降低权重预期,0 个 Release 给 2 分(而非 1 分) -- ⚠️ **搜索结果无分页参数**,每次返回约 20 条。关键词超过 5 个时需手动截断合并结果 -- ⚠️ **本 Skill 场景适配 GitLink 平台**,GitLink 以国内开发者和企业项目为主,搜索结果可能偏向中文技术生态,且镜像项目较多 +- ⚠️ **v1.1 新增 `search +code` 和 `+issues`**:仅对 2-3 个核心关键词执行,控制 API 调用总量 +- ⚠️ **`search +repos` 和 `repo +info` 字段名不同**:搜索结果用 `praises_count`/`forked_count`,`repo +info` 有 `watchers_count`/`full_name`/`mirror` +- ⚠️ **`repo +info` 并发请求可能触发 TLS 超时**,失败时等 5 秒重试一次 +- ⚠️ **GitLink 平台镜像仓库比例高**,镜像仓库的社区数据为 0 +- ⚠️ **GitLink Release 功能使用率低**,0 个 Release 给 2 分(非镜像) +- ⚠️ **本 Skill 场景适配 GitLink 平台**,结果可能偏向中文技术生态 diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index c759450..858a827 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -34,7 +34,7 @@ gitlink-cli auth logout - GitLink Token 有效期 **7 天**,过期需重新登录 - Token 存储在 OS Keychain(macOS Keychain / Linux Secret Service / Windows Credential Manager) -- Fallback 存储:`$GITLINK_CONFIG_DIR/credentials`(未设置时为 `~/.config/gitlink-cli/credentials`) +- Fallback 存储:`~/.config/gitlink-cli/credentials` ### 认证错误处理 @@ -63,27 +63,6 @@ gitlink-cli auth login - HTTPS: `https://www.gitlink.org.cn/owner/repo.git` - SSH: `git@www.gitlink.org.cn:owner/repo.git` -## Shell 自动补全 - -安装后可以按用户当前 shell 生成补全脚本,帮助用户发现 Shortcut 子命令和参数: - -```bash -# Bash -gitlink-cli completion bash > ~/.local/share/bash-completion/completions/gitlink-cli - -# Zsh -gitlink-cli completion zsh > "${fpath[1]}/_gitlink-cli" - -# Fish -gitlink-cli completion fish > ~/.config/fish/completions/gitlink-cli.fish - -# PowerShell -gitlink-cli completion powershell > gitlink-cli.ps1 -. ./gitlink-cli.ps1 -``` - -如果终端补全不需要描述文本,可以追加 `--no-descriptions`。 - ## 输出格式 所有命令输出遵循统一 Envelope 格式: @@ -111,8 +90,7 @@ gitlink-cli completion powershell > gitlink-cli.ps1 | 层级 | 格式 | 示例 | 适用场景 | |------|------|------|----------| | Shortcuts | `gitlink-cli +` | `gitlink-cli repo +info` | 高频操作,推荐优先使用 | -| Raw API | `gitlink-cli api ` | `gitlink-cli api GET /users/me` | Shortcuts 未覆盖的接口 | -| Raw API 批处理 | `gitlink-cli api --batch-file ` | `gitlink-cli api --batch-file plan.json --dry-run` | 对未封装接口做可审计的批量自动化 | +| Raw API | `gitlink-cli api ` | `gitlink-cli user +me` | Shortcuts 未覆盖的接口 | ## GitLink API 注意事项 @@ -129,11 +107,8 @@ gitlink-cli completion powershell > gitlink-cli.ps1 | Create File 需要 base64 | `POST /:owner/:repo/create_file` 的 `content` 字段必须 base64 编码 | 不编码会返回"文件已存在"错误 | | Update File 需要 SHA | `PUT /:owner/:repo/update_file` 需要 `sha` 参数,通过 `sub_entries` 接口获取 | 见下方文件操作说明 | | PR 合并需要 `do` 参数 | `pr +merge` 需传 `do` 字段指定合并方式(merge/rebase/squash) | `pr +merge` 已内置处理 | +| PR 列表 state 过滤 | `--state` 参数仅影响统计计数,返回列表可能包含所有状态 | 需通过 `pull_request_status` 字段客户端过滤:0=open, 1=merged, 2=closed | | PR 创建需要代码差异 | 分支内容必须与目标分支不同,否则拒绝创建 | 需要先在分支上有实际提交 | -| **PR 列表可能返回空** | 部分仓库(如 fork、权限受限)`pr +list` 返回 `pulls:[]`,但 `repo +info` 的 `pull_requests_count` 非零 | 平台 quirk;用 `pull_requests_count`(总数)+ `git log --merges`(本地合并历史)兜底 | -| **repo +contributor-stats 报错** | `repo +contributor-stats` 可能返回「获取贡献者(代码行)失败」 | API 不稳;改用 `repo +contributors`(含行数,但口径含纯邮箱提交者) | -| **commits/tags/releases 无 JSON API** | 这些列表端点返回 SPA HTML(非 JSON),故**无 `repo +commits`/`+tags`/`+raw` 命令** | 平台未开放;提交/标签历史用 `git clone`+`git log`/`git tag` 本地获取;读文件内容用 `file +get` 替代 `repo +raw` | -| **贡献者口径不一致** | `repo +info` 的 `contributor_users_count` 只数 GitLink 注册用户;`repo +contributors` 返回含纯邮箱提交者(数量更多) | 巴士因子/协作分析用 `repo +contributors` 列表;「注册贡献者数」用 `contributor_users_count` | ## 文件操作 API @@ -144,7 +119,7 @@ gitlink-cli completion powershell > gitlink-cli.ps1 ```bash # content 必须 base64 编码 CONTENT=$(echo -n "文件内容" | base64) -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "path/to/file.md", "content": "", "branch": "feature-branch", @@ -156,11 +131,11 @@ gitlink-cli api POST /:owner/:repo/create_file --body '{ ```bash # Step 1: 获取文件 SHA -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=path/to/file.md&ref=branch-name' +gitlink-cli repo +files --query 'filepath=path/to/file.md&ref=branch-name' # 从返回的 entries.sha 获取 SHA 值 # Step 2: 更新文件(content 必须 base64 编码) -gitlink-cli api PUT /:owner/:repo/update_file --body '{ +gitlink-cli repo +update-file --body '{ "filepath": "path/to/file.md", "content": "", "sha": "<从sub_entries获取的sha>", @@ -173,7 +148,7 @@ gitlink-cli api PUT /:owner/:repo/update_file --body '{ ```bash # 需要文件 SHA -gitlink-cli api DELETE /:owner/:repo/delete_file --body '{ +gitlink-cli repo +delete-file --body '{ "filepath": "path/to/file.md", "sha": "", "branch": "master", diff --git a/skills/gitlink-shared/references/api-reference.md b/skills/gitlink-shared/references/api-reference.md index 13c6d84..3c258b7 100644 --- a/skills/gitlink-shared/references/api-reference.md +++ b/skills/gitlink-shared/references/api-reference.md @@ -73,3 +73,100 @@ | Branch 删除返回"不存在" | 无法删除分支 | 待 GitLink 修复 | | Release 删除返回"不存在" | 无法删除发布 | 待 GitLink 修复 | | Create File 返回"已存在" | 无法通过 API 创建文件 | 待 GitLink 修复 | +| `api` 命令路径以 `/` 开头会被解析为本地路径 | 返回 HTML 而非 JSON | 去掉路径前导 `/` 即可 | + +--- + +## 消息(通知)API + +GitLink 的通知功能通过「消息」API 实现。 + +### 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/users/{owner}/messages.json` | GET | 获取用户消息列表 | +| `/users/{owner}/messages/{id}/read` | POST | 标记单条消息已读 | +| `/users/{owner}/messages/{id}` | DELETE | 删除消息 | +| `/users/{owner}/messages/settings` | GET | 平台消息设置 | +| `/users/{owner}/messages/settings/list` | GET | 用户消息设置列表 | +| `/users/{owner}/messages/settings/update` | POST | 更新用户消息设置 | + +### 查询参数(GET messages.json) + +| 参数 | 类型 | 说明 | +|------|------|------| +| `status` | integer | 1=未读,2=已读,不传=全部 | +| `type` | string | `notification`=系统消息,`atme`=@我消息,不传=全部 | +| `page` | integer | 页码(默认 1) | +| `limit` | integer | 每页条数(默认 20) | + +### 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total_count` | integer | 总消息数 | +| `unread_notification` | integer | 未读系统消息数 | +| `unread_atme` | integer | 未读@我消息数 | +| `messages[].id` | integer | 消息唯一 ID | +| `messages[].status` | integer | 1=未读,2=已读 | +| `messages[].content` | string | HTML 格式的消息内容 | +| `messages[].source` | enum | 消息来源类型(见下方枚举表) | +| `messages[].notification_url` | string | 消息跳转链接 | +| `messages[].created_at` | string | 创建时间(YYYY-MM-DD HH:mm:ss) | +| `messages[].time_ago` | string | 相对时间描述 | +| `messages[].type` | string | `notification` 或 `atme` | +| `messages[].sender` | object | 发送者信息(id, name, login, image_url) | + +### source 枚举值 + +| 枚举值 | 含义 | 分类 | +|--------|------|------| +| `IssueAssigned` | 有新指派给我的疑修 | Issue | +| `IssueExpire` | 疑修截止日期到达最后一天 | Issue | +| `IssueAtme` | 在疑修中@我 | @提及 | +| `IssueChanged` | 疑修状态变更 | Issue | +| `IssueDeleted` | 疑修被删除 | Issue | +| `IssueJournal` | 疑修有新评论 | Issue | +| `ProjectIssue` | 项目新 Issue | Issue | +| `PullRequestAssigned` | 有新指派给我的 PR | PR | +| `PullReuqestAtme` | 在 PR 中@我(**官方 API 拼写如此**) | @提及 | +| `PullRequestChanged` | PR 状态变更 | PR | +| `PullRequestClosed` | PR 被关闭 | PR | +| `PullRequestJournal` | PR 有新评论 | PR | +| `PullRequestMerged` | PR 已合并 | PR | +| `ProjectPullRequest` | 项目有新 PR | PR | +| `ProjectJoined` | 加入项目 | 系统 | +| `ProjectLeft` | 离开项目 | 系统 | +| `ProjectMemberJoined` | 新成员加入项目 | 系统 | +| `ProjectMemberLeft` | 成员离开项目 | 系统 | +| `ProjectForked` | 项目被 Fork | 系统 | +| `ProjectPraised` | 项目被点赞 | 系统 | +| `ProjectRole` | 项目角色变更 | 系统 | +| `ProjectFollowed` | 项目被关注 | 系统 | +| `ProjectDeleted` | 项目被删除 | 系统 | +| `ProjectTransfer` | 项目转让 | 系统 | +| `ProjectSettingChanged` | 项目设置变更 | 系统 | +| `ProjectMilestone` | 新里程碑 | 系统 | +| `ProjectMilestoneCompleted` | 里程碑完成 | 系统 | +| `ProjectVersion` | 新版本发布 | 系统 | +| `ProjectOpenDevOps` | DevOps 引擎开通 | 系统 | +| `OrganizationJoined` | 加入组织 | 系统 | +| `OrganizationLeft` | 离开组织 | 系统 | +| `OrganizationRole` | 组织角色变更 | 系统 | +| `LoginIpTip` | 登录 IP 提示 | 其他 | + +### 调用示例 + +```bash +# 获取未读通知 +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json + +# 获取 @我 的通知 +gitlink-cli api GET "users/lindiwen23/messages.json" --query "type=atme&status=1" --format json + +# 标记单条已读 +gitlink-cli api POST "users/lindiwen23/messages/740214/read" --format json + +# ⚠️ 路径不要以 / 开头,否则会被解析为本地文件路径 +``` diff --git a/skills/gitlink-shared/references/troubleshooting.md b/skills/gitlink-shared/references/troubleshooting.md index 79040fe..d0aeffa 100644 --- a/skills/gitlink-shared/references/troubleshooting.md +++ b/skills/gitlink-shared/references/troubleshooting.md @@ -58,7 +58,7 @@ gitlink-cli repo +info --owner xxx --repo yyy gitlink-cli issue +create -t "标题" -b "描述" # 或使用 Raw API 时添加 done_ratio -gitlink-cli api POST /:owner/:repo/issues --body '{ +gitlink-cli issue +create '{ "subject": "标题", "description": "描述", "done_ratio": 0 @@ -75,7 +75,7 @@ gitlink-cli api POST /:owner/:repo/issues --body '{ gitlink-cli issue +close -i 123 # 或使用 Raw API 时先 GET 当前 Issue,再添加 subject 和 description -gitlink-cli api PUT /:owner/:repo/issues/123 --body '{ +gitlink-cli issue +update --number 123 '{ "subject": "当前标题", "description": "当前描述", "status_id": 5 @@ -152,7 +152,7 @@ gitlink-cli --debug ### 查看完整 API 请求 ```bash -gitlink-cli api GET /users/me --debug +gitlink-cli user +me --debug ``` ### 检查认证状态 diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 62df0bb..7574a75 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -22,12 +22,6 @@ metadata: |----------|------|----------| | `user +me` | 当前登录用户 | 是 | | `user +info` | 查看用户详情 | 否 | -| `user +headmaps` | 贡献热力图 | 否 | -| `user +stats-activity` | 活跃度统计 | 否 | -| `user +stats-develop` | 开发能力统计 | 否 | -| `user +stats-role` | 角色定位统计 | 否 | -| `user +stats-major` | 专业定位统计 | 否 | -| `user +trends` | 项目动态趋势 | 否 | ## 使用示例 @@ -37,20 +31,17 @@ gitlink-cli user +me # 查看其他用户 gitlink-cli user +info --login zhangsan - -# 查看贡献热力图 -gitlink-cli user +headmaps --login zhangsan - -# 查看统计信息 -gitlink-cli user +stats-activity --login zhangsan -gitlink-cli user +stats-develop --login zhangsan -gitlink-cli user +stats-role --login zhangsan -gitlink-cli user +stats-major --login zhangsan - -# 查看项目动态 -gitlink-cli user +trends --login zhangsan --limit 20 ``` -## 注意事项 +## Raw API 补充 -- 查看其他用户信息需要提供 `--login` 参数 +```bash +# 用户贡献热力图 +gitlink-cli user +heatmap + +# 用户统计 +gitlink-cli user +stats + +# 用户项目动态 +gitlink-cli user +trends +``` diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index fd4122a..156a8e7 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -28,7 +28,7 @@ gitlink-cli issue +list --state open --format json gitlink-cli issue +view --id --format json # 3. 根据内容分析,通过 Raw API 添加标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[]}' +gitlink-cli issue +update --number '{"issue_tag_ids":[]}' ``` **分类规则建议**: @@ -44,40 +44,31 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[ # 1. 获取 PR 详情 gitlink-cli pr +view --id --format json -# 2. 获取完整审查上下文(仓库、PR、变更文件、Review、Issue、标签) -gitlink-cli workflow +review-context --number --format json +# 2. 获取变更文件列表 +gitlink-cli pr +files --id --format json -# 3. 添加 Review 评论(写操作前需确认用户意图) -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审查意见...","event":"COMMENT"}' +# 3. 获取 PR 提交列表 +gitlink-cli pr +diff --id --format json + +# 4. 添加 Review 评论 +gitlink-cli pr +review --body '{"body":"代码审查意见...","event":"COMMENT"}' ``` ## 工作流 3:Release Notes 生成 -**场景**:从提交历史和 PR 信号自动生成版本发布说明,适合维护者发版前检查、Agent 生成 changelog 草稿、竞赛材料展示等场景。 +**场景**:从提交历史自动生成版本发布说明。 ```bash -# 只读远程模式:从 GitLink compare 数据生成 Markdown -gitlink-cli workflow +release-notes \ - --owner Gitlink \ - --repo gitlink-cli \ - --from-ref v1.1.0 \ - --to-ref master \ - --version v1.2.0 \ - --format markdown +# 1. 获取两个版本之间的提交 +gitlink-cli repo +compare --format json -# 本地 JSON 模式:供 Agent 流水线、测试夹具或离线复现使用 -gitlink-cli workflow +release-notes \ - --from shortcuts/workflow/testdata/release_notes.json \ - --format json +# 2. 获取已关闭的 Issue +gitlink-cli issue +list --state closed --format json + +# 3. 生成 Release Notes 并创建发布 +gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)" ``` -规则: -- 优先使用 `workflow +release-notes` 生成草稿,再由维护者决定是否创建 Release。 -- 使用 `--format json` 作为 Agent 间传递格式;使用 `--format markdown` 作为人类可读发布说明。 -- 远程模式只读取 compare 数据,不创建 Release,不评论、不打标签、不合并。 -- `--include-prs` 默认开启;当 compare 响应包含 PR 信号时会一起分类。 -- 分类规则是确定性的,不依赖 LLM API,便于审计和复现。 - ## 工作流 4:Repo Setup(仓库初始化) **场景**:创建仓库并完成基础配置。 @@ -106,8 +97,8 @@ gitlink-cli issue +list --state closed --format json gitlink-cli pr +list --state open --format json gitlink-cli pr +list --state merged --format json -# 3. 获取仓库工作流报告 -gitlink-cli workflow +repo-report --format json +# 3. 获取项目动态 +gitlink-cli repo +activity --format json ``` ## Workflow: PR Summary (Read-only) @@ -128,28 +119,6 @@ Rules: - This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests. - Do not use LLM APIs for this workflow; it is rule-based and explainable. -## Workflow: Review Context (Read-only) - -Use `workflow +review-context` when an Agent needs one deterministic JSON bundle for PR review or gatekeeping. It aggregates shortcut-backed read-only fetches for repository info, PR details, changed files, existing reviews, open issues, and labels. - -```bash -gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json - -# Trim context for large repositories -gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \ - --issue-limit 10 --label-limit 30 --format json - -# Only fetch PR and changed files -gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \ - --include-repo=false --include-reviews=false --include-issues=false --include-labels=false \ - --format json -``` - -Rules: -- This command is read-only and never comments, approves, rejects, merges, labels, or closes resources. -- Prefer it before `workflow +pr-summary` when a review agent needs raw context plus existing review state. -- The command records partial fetch failures in `notes` so Agents can proceed with available context. - ## Workflow: Repo Report (Read-only) Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report