Merge PR #103: feat: 新建 pm 模块,添加 6 条项目管理命令

# Conflicts:
#	cmd/alias/alias.go
#	cmd/alias/alias_test.go
#	cmd/browse/browse.go
#	cmd/browse/browse_test.go
#	cmd/root.go
#	internal/client/client.go
#	internal/client/client_test.go
#	shortcuts/notification/notification.go
#	shortcuts/pm/pm.go
#	shortcuts/pm/pm_test.go
#	shortcuts/pr/pr.go
#	shortcuts/register.go
#	shortcuts/repo/repo_test.go
#	shortcuts/user/user.go
#	shortcuts/wiki/wiki.go
#	shortcuts/wiki/wiki_test.go
#	skills/gitlink-ci/SKILL.md
#	skills/gitlink-code-review/SKILL.md
#	skills/gitlink-commit-quality/SKILL.md
#	skills/gitlink-compliance/SKILL.md
#	skills/gitlink-contributor-insight/EXAMPLES.md
#	skills/gitlink-contributor-insight/SKILL.md
#	skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md
#	skills/gitlink-insight/SKILL.md
#	skills/gitlink-issue-triage/SKILL.md
#	skills/gitlink-notification-digest/EXAMPLES.md
#	skills/gitlink-notification-digest/SKILL.md
#	skills/gitlink-org/SKILL.md
#	skills/gitlink-repo/SKILL.md
#	skills/gitlink-shared/SKILL.md
#	skills/gitlink-user/SKILL.md
#	skills/gitlink-workflow/SKILL.md
This commit is contained in:
wbtiger 2026-07-14 22:50:21 +08:00
commit b8589dc93c
59 changed files with 3191 additions and 3644 deletions

41
.devops/ci.yml Normal file
View File

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

View File

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

View File

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

View File

@ -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 <name> <command>": false, "+delete <name>": false, "+expand <name>": 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)
}
}

View File

@ -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 <kind>[/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 <arg>` 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()
}

View File

@ -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 <args>` 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 <resource>` 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 就行
}

View File

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

View File

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

49
doc/commands/wiki.md Normal file
View File

@ -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\>
- **参数**: --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 域组 + 前缀添加。

View File

@ -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 (<?xml ...?>) 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., <?xml version="1.0"?>)
if bytes.HasPrefix(trimmed, []byte("<?")) {
if idx := bytes.Index(trimmed, []byte("?>")); idx != -1 {
trimmed = bytes.TrimSpace(trimmed[idx+2:])
}
}
if len(trimmed) == 0 {
return false
}
// Check for HTML document prefixes
prefixes := []string{"<!DOCTYPE", "<html", "<HTML", "<!doctype"}
for _, p := range prefixes {
if bytes.HasPrefix(trimmed, []byte(p)) {
return true
}
}
return false
}
func suggestHTMLFix() string {
return "API 返回了 HTML 页面而非 JSON 数据。" +
"可能原因:\n" +
" 1. 未登录或 Token 已过期 → 运行 gitlink-cli auth login\n" +
" 2. Token 权限不足 → 在 GitLink 平台重新生成 Token\n" +
" 3. API 端点不存在 → 检查路径是否正确\n" +
" 4. 使用 Shortcut 命令替代 Raw API → 运行 gitlink-cli --help 查看可用命令"
}
func suggestFix(code int) string {
switch code {
case 401:
@ -361,3 +261,4 @@ func suggestFix(code int) string {
return ""
}
}

View File

@ -2,8 +2,6 @@ package client
import (
"encoding/json"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
@ -172,45 +170,6 @@ func TestClientDoStatusError(t *testing.T) {
}
}
func TestClientDoGatewayCodeError(t *testing.T) {
// Gateway returns {"code":N, "msg":"..."} instead of {"status":N, "message":"..."}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"Bad Request"}`))
}))
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 code=400")
}
if env == nil {
t.Fatal("expected envelope for code error")
}
if env.OK {
t.Fatal("expected OK=false for code=400")
}
}
func TestClientDoGatewayCode201Success(t *testing.T) {
// Gateway returns code=201 with JSON string data — should be treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":201,"msg":"","data":"{\"title\":\"test\"}"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("POST", "/api/test", map[string]string{"title": "test"}, nil)
if err != nil {
t.Fatalf("unexpected error for code=201: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for code=201")
}
}
func TestClientDoStatusZero(t *testing.T) {
// status=0, 200, 1 are treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -315,87 +274,6 @@ func TestClientDoWithBody(t *testing.T) {
}
}
func TestClientPostMultipart(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/attachments.json" {
t.Fatalf("expected path /api/attachments.json, got %s", r.URL.Path)
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("parse media type: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("Content-Type = %q, want multipart/form-data", mediaType)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm: %v", err)
}
if got := r.FormValue("description"); got != "release asset" {
t.Fatalf("description = %q, want %q", got, "release asset")
}
files := r.MultipartForm.File["file"]
if len(files) != 1 {
t.Fatalf("expected 1 uploaded file, got %d", len(files))
}
if files[0].Filename != "asset.zip" {
t.Fatalf("filename = %q, want %q", files[0].Filename, "asset.zip")
}
if got := files[0].Header.Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("part Content-Type = %q, want %q", got, "application/octet-stream")
}
file, err := files[0].Open()
if err != nil {
t.Fatalf("open multipart file: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read multipart file: %v", err)
}
if string(data) != "binary content" {
t.Fatalf("file body = %q, want %q", string(data), "binary content")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"asset-1"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.PostMultipart("/api/attachments", map[string]string{
"description": "release asset",
}, []MultipartFile{
{
FieldName: "file",
FileName: "asset.zip",
ContentType: "application/octet-stream",
Reader: strings.NewReader("binary content"),
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true")
}
}
func TestClientPostMultipartRequiresFile(t *testing.T) {
c := &Client{HTTP: &http.Client{}, BaseURL: "https://gitlink.example.com"}
_, err := c.PostMultipart("/attachments", nil, nil)
if err == nil {
t.Fatal("expected error when no multipart file is provided")
}
}
func TestClientGet(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
@ -631,100 +509,6 @@ func TestPaginateAllNotOK(t *testing.T) {
}
}
func TestClientDownload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/attachments/7" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("asset bytes"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api"}
result, err := c.Download("/api/attachments/7")
if err != nil {
t.Fatalf("Download error: %v", err)
}
if string(result.Data) != "asset bytes" {
t.Fatalf("data = %q", result.Data)
}
if result.ContentType != "application/octet-stream" {
t.Fatalf("content type = %q", result.ContentType)
}
}
func TestClientDownloadAPIPathWithV1BaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/attachments/7" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Write([]byte("asset bytes"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api/v1"}
result, err := c.Download("/api/attachments/7")
if err != nil {
t.Fatalf("Download error: %v", err)
}
if string(result.Data) != "asset bytes" {
t.Fatalf("data = %q", result.Data)
}
}
func TestClientDownloadAbsoluteURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/files/release.zip" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Write([]byte("zip bytes"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: "https://gitlink.example.com/api"}
result, err := c.Download(server.URL + "/files/release.zip")
if err != nil {
t.Fatalf("Download error: %v", err)
}
if string(result.Data) != "zip bytes" {
t.Fatalf("data = %q", result.Data)
}
}
func TestClientDownloadWebRelativeURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/releases/download/v1/app.zip" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Write([]byte("asset bytes"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api"}
result, err := c.Download("/owner/repo/releases/download/v1/app.zip")
if err != nil {
t.Fatalf("Download error: %v", err)
}
if string(result.Data) != "asset bytes" {
t.Fatalf("data = %q", result.Data)
}
}
func TestClientDownloadHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("missing"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Download("/missing")
if err == nil {
t.Fatal("expected download error")
}
}
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
t.Fatal("raw file path should not get .json suffix")
@ -743,17 +527,66 @@ func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
}
}
func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) {
paths := []string{
"/wiki/open/createWiki",
"/wiki/open/getWiki",
"/wiki/open/updateWiki",
"/wiki/open/deleteWiki",
"/wiki/open/wikiPages",
func TestDetectHTMLResponse(t *testing.T) {
tests := []struct {
name string
body string
wantHTML bool
}{
{"正常 JSON", `{"key":"value"}`, false},
{"DOCTYPE 开头", `<!DOCTYPE html><html>...</html>`, true},
{"html 小写开头", `<html><head>...</head></html>`, true},
{"HTML 大写开头", `<HTML><HEAD>...</HEAD></HTML>`, true},
{"doctype 小写开头", `<!doctype html><html lang="en">`, true},
{"空响应体", "", false},
{"纯文本", `just some text`, false},
{"空白后 HTML", ` <!DOCTYPE html>`, true},
{"JSON 数组", `[1,2,3]`, false},
{"HTML 片段(无前缀)", `<body>content</body>`, false},
{"XML 声明后跟 HTML", `<?xml version="1.0"?><!DOCTYPE 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(`<!DOCTYPE html><html><head><title>Sign in</title></head><body>Please log in</body></html>`))
}))
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")
}
}

40
scripts/install.ps1 Normal file
View File

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

49
scripts/install.sh Normal file
View File

@ -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 验证安装"

View File

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

View File

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

View File

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

View File

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

View File

@ -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 未找到,请确认仓库是否存在")
}

View File

@ -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, `<!DOCTYPE html><html><body>Login</body></html>`, 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
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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 未找到,请确认仓库是否存在")
}

View File

@ -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, `<!DOCTYPE html><html><body>Login</body></html>`, 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
}

View File

@ -0,0 +1,178 @@
# gitlink-ci-health 使用样例
## 样例 1CI 未激活的仓库
**日期**2026-06-03
**仓库**jiangtx/gitlink-cliFork 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-cliFork 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/CDDevOps服务。
## 三、改进建议
- 🔴 立即激活 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 字段为 falseCI/CD 服务未开启。其他仓库信息2 位贡献者、
9 个 PR、0 个 IssueFork 自 Gitlink/gitlink-cli。
```
### 验证结论
✅ skill v1.1.0 修复验证通过:
- Agent 正确使用了 `repo +info``open_devops` 字段判断 CI 状态
- Agent 在 CI 未激活时正确终止,没有执行后续无效步骤
- Agent 没有尝试调用不存在的 `ci +authorize``ci +activate`
- Agent 正确建议通过 Web 界面激活
- 报告结构完整,包含了仓库基本信息

View File

@ -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-healthCI 健康巡检)
**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 <owner> --repo <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 <owner> --repo <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 <owner> --repo <repo> --build <build_id> --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次全部成功=58-9次=46-7次=34-5次=2<4次=1 |
| 构建频率 | 3 | 每天有构建=32-3天=2每周=1更少=0 |
| 修复速度 | 3 | 失败后1次内修复=32-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 是否激活的最可靠方式

View File

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

View File

@ -160,7 +160,7 @@ gitlink-cli repo +info --owner <owner> --repo <repo> --format json
### 获取仓库文件列表
```bash
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
gitlink-cli repo +files --query 'filepath=<path>&ref=<branch>' --format json
```
**返回字段说明:**
@ -177,7 +177,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<bran
### 获取仓库语言统计
```bash
gitlink-cli api GET /:owner/:repo/languages --format json
gitlink-cli repo +languages --format json
```
**返回示例:**
@ -188,7 +188,7 @@ gitlink-cli api GET /:owner/:repo/languages --format json
### 获取仓库原始文件
```bash
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
gitlink-cli repo +raw --ref=<branch>/<filepath>
```
---
@ -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
```
| 字段 | 类型 | 说明 |

View File

@ -109,13 +109,13 @@ gitlink-cli pr +diff --id <pr_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": "<commit_sha>",
@ -164,18 +164,18 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
# 2. 获取仓库文件列表(遍历关键目录)
gitlink-cli repo +tree --owner <owner> --repo <repo> --path src --ref master --format json
gitlink-cli repo +tree --owner <owner> --repo <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 <owner> --repo <repo> --path README.md --ref master --format json
gitlink-cli repo +raw --owner <owner> --repo <repo> --path .gitignore --ref master --format json
gitlink-cli repo +raw --owner <owner> --repo <repo> --path .eslintrc.js --ref master --format json
gitlink-cli repo +manifest --owner <owner> --repo <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 <issue_id> --format json
# 3. 根据内容智能分类
# 分析标题和描述后,通过 Raw API 打标签
gitlink-cli api POST /:owner/:repo/issues/:id --body '{
gitlink-cli issue +update --number '{
"issue_tag_ids": [<tag_id>],
"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 <owner> --repo <repo> --path <path> --ref <branch> --format json
gitlink-cli repo +files --query 'filepath=<path>&ref=<branch>'
# 获取仓库语言统计
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
```
## 代码审查最佳实践

View File

@ -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 <id> --format json
gitlink-cli pr +diff --id <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"}'
```

View File

@ -59,7 +59,10 @@ metadata:
```bash
# 获取 PR 关联的提交列表
gitlink-cli pr +commits --id <pr_id> --owner <owner> --repo <repo> --format json
gitlink-cli pr +diff --id <pr_id> --owner <owner> --repo <repo> --format json
# 或通过 Raw API 获取提交详情
gitlink-cli pr +commits --format json
```
### Commit Message 质量检查清单

View File

@ -7,7 +7,7 @@
### 读取文件内容
```bash
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
gitlink-cli repo +raw --ref=<branch>/<filepath>
```
**说明:** 直接返回文件原始内容,用于检查 LICENSE、README、CONTRIBUTING 等文件。
@ -15,7 +15,7 @@ gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
### 获取文件列表
```bash
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
gitlink-cli repo +files --query 'filepath=<path>&ref=<branch>' --format json
```
| 字段 | 类型 | 说明 |
@ -45,7 +45,7 @@ gitlink-cli repo +info --owner <owner> --repo <repo> --format json
### 获取贡献者列表
```bash
gitlink-cli api GET /:owner/:repo/contributors --format json
gitlink-cli repo +contributors --format json
```
用于检查贡献者是否签署了 CLA/DCO。

View File

@ -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 <owner> --repo <repo> --ref master --format json
gitlink-cli repo +files --query 'filepath=&ref=master'
# 2. 读取 LICENSE 文件
gitlink-cli repo +raw --owner <owner> --repo <repo> --path LICENSE --ref master --format json
gitlink-cli repo +raw --ref=master/LICENSE
# 3. 检查关键文档是否存在
# 检查以下文件是否存在:
@ -74,14 +54,14 @@ gitlink-cli repo +raw --owner <owner> --repo <repo> --path LICENSE --ref master
# - README.md
# 4. 获取依赖配置
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind node --ref master --format json
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind go --ref master --format json
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind python --ref master --format json
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind rust --ref master --format json
gitlink-cli repo +manifest --owner <owner> --repo <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 <owner> --repo <repo> --path src --ref master --format json
gitlink-cli repo +files --query 'filepath=src&ref=master'
# 6. 获取仓库基本信息
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
@ -166,7 +146,7 @@ gitlink-cli repo +info --owner <owner> --repo <repo> --format json
```bash
# 1. 获取依赖配置文件
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind node --ref master --format json
gitlink-cli repo +raw --ref=master/package.json
```
### 许可证兼容性参考
@ -201,10 +181,10 @@ gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind node --ref maste
```bash
# 1. 遍历源文件目录
gitlink-cli repo +tree --owner <owner> --repo <repo> --path src --ref master --format json
gitlink-cli repo +files --query 'filepath=src&ref=master'
# 2. 采样检查源文件头部(取前 5-10 行)
gitlink-cli repo +raw --owner <owner> --repo <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 <owner> --repo <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 <owner> --repo <repo> repo +info --format json
# 关键文件文本LICENSE / README / go.mod / requirements.txt / package.json / .gitignore / SECURITY.md / ...
gitlink-cli --owner <owner> --repo <repo> file +get --path LICENSE --ref master
# 根文件树(扫 data/、.env、config、.gitea/.github workflows 等是否存在)
gitlink-cli --owner <owner> --repo <repo> repo +tree --ref master
# 语言占比(仅作为元信息记录)
gitlink-cli --owner <owner> --repo <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
# 默认输出到 stdoutJSON
python scripts/research/repro.py --owner mindspore-Ecosystem --repo mindspore
# 输出两件产物到目录repro.json + compliance_report.md
python scripts/research/repro.py --owner <OWNER> --repo <REPO> --out ./out
# 可复现脚本(封装了上述流程)
bash skills/gitlink-compliance/examples/compliance-repro-workflow.sh <OWNER> <REPO> [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 <owner> --repo <repo> --path <path> --ref <branch> --format json
gitlink-cli repo +raw --ref=<branch>/<path>
# 获取文件列表(遍历目录)
gitlink-cli repo +tree --owner <owner> --repo <repo> --path <path> --ref <branch> --format json
gitlink-cli repo +files --query 'filepath=<path>&ref=<branch>'
# 获取仓库信息
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
```
## 注意事项

View File

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

View File

@ -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 <owner> --repo <repo> --format json
```bash
# 用户基本信息
gitlink-cli user +info --login <username> --format json
# 贡献热力图、聚合统计、项目趋势
gitlink-cli user +heatmap --user <username> --format json
gitlink-cli user +stats --user <username> --format json
gitlink-cli user +trends --user <username> --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 <username> --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 <username> --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 <username> --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) 包含原始命令输出数据

View File

@ -130,9 +130,7 @@ PR 详细列表:
}
```
### 5. 不可用的命令gitlink-cli 0.1.18 历史记录)
> 当前版本已新增 `user +heatmap`、`user +stats`、`user +trends`。下表仅记录本样例在 0.1.18 上的历史执行结果。
### 5. 不可用的命令
| 命令 | 结果 |
|------|------|

View File

@ -87,7 +87,7 @@ gitlink-cli release +list --format json
### 仓库文件列表
```bash
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=<branch>' --format json
gitlink-cli repo +files --query 'filepath=&ref=<branch>' --format json
```
| 字段 | 类型 | 说明 |
@ -100,7 +100,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=<branch>' -
### 仓库语言统计
```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
```
| 字段 | 类型 | 说明 |

View File

@ -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 <owner> --repo <repo> --ref master --format json
gitlink-cli repo +files --query 'filepath=&ref=master'
# 5. 获取语言统计
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
gitlink-cli repo +languages --format json
# 6. 获取贡献者列表
gitlink-cli repo +contributors --owner <owner> --repo <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 <owner> --repo <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 <owner> --repo <repo> --format json
gitlink-cli repo +info --format json
# 仓库语言统计
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
gitlink-cli repo +languages --format json
# 贡献者列表
gitlink-cli repo +contributors --owner <owner> --repo <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 <owner> --repo <repo> --ref master --format json
gitlink-cli repo +files --query 'filepath=&ref=master'
# 获取用户信息
gitlink-cli user +info --login <user_login> --format json
gitlink-cli user +info --login --format json
# 用户贡献热力图
gitlink-cli user +heatmap --user <user_login> --format json
gitlink-cli user +heatmap --format json
```
## 注意事项

View File

@ -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生成周报

View File

@ -1,7 +1,7 @@
---
name: gitlink-issue-triage
version: 1.0.0
description: "Issue 智能分拣:扫描未分类 IssueAI 按语义/关键词自动分类打标签、推荐并分配责任人,再用 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-triageIssue 智能分拣)
**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 <owner> --repo <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 <owner> --repo <repo> --state open --format json
```
### Step 2AI 筛选"未分类"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 <owner> --repo <repo> --number <n> --format json
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json --page 2
```
### Step 4AI 按分类规则表判断类型
### Step 3逐条深入分析
综合标题(`subject`)和描述(`description`)做语义分类,输出"类型 + 依据"。
### Step 5查找或创建对应标签
对过滤后的每条 Issue获取详情
```bash
# 先查现有标签,命中则复用 ID
gitlink-cli label +list --owner <owner> --repo <repo> --format json
# 仅当现有标签里没有对应分类时才创建注意GitLink 标签名限 15 字符)
gitlink-cli label +create --owner <owner> --repo <repo> \
--name "bug" --color "#ee0701"
gitlink-cli issue +view --owner <owner> --repo <repo> --number <project_issues_index> --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 新增)
对高优先级 Issueurgent/high获取活动日志以了解处理进展
```bash
# --label 是"覆盖"语义Issue 已有标签时必须把原 ID 一并传入
gitlink-cli issue +update --owner <owner> --repo <repo> \
--number <n> --label <tag_id>[,<原标签id>...]
gitlink-cli issue +journals --owner <owner> --repo <repo> --number <project_issues_index> --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 <owner> --repo <repo> --ids <id1,id2,id3> --status closed --format json
```
> ⚠️ **写操作**:执行前需向用户展示将要操作的 Issue 列表,获得确认后再执行。
典型使用场景:
- 批量关闭 `close-candidate` 列表中的 Issue
- 批量将 `good-first-issue` 标记为 open确保状态正确
---
## 输出模板
```markdown
## 🏷️ Issue 分类报告 — <owner>/<repo>
# 📊 {{仓库名}} Issue 分拣报告
📅 分拣时间:<YYYY-MM-DD HH:MM>
> 分析时间:{{当前时间}}
> Issue 总数:{{total}},开放:{{open_count}},本次分析:{{analyzed_count}} 条
| Issue | 标题(节选) | 分类 | 依据 | 标签 ID |
|-------|------------|:----:|------|:------:|
| #12 | 登录后偶发 500 报错 | bug | "500 报错"语义 | 382700 |
| #13 | 希望支持 webhook 自定义 header | enhancement | "希望支持" | 382701 |
---
### 📊 汇总
- 处理2 个未分类 Issue
- bug × 1🔴 高enhancement × 1🔵 低)
- 兜底 triage0 个
## 总览
| 指标 | 数量 |
|------|------|
| 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 <owner> --repo <repo> --ids {{close_ids}} --status closed`
```
---
## 工作流 2自动分配 + 通知
## 异常场景处理
**触发场景:** "帮我给这些 Issue 分配责任人,并通知他们"
### Step 1获取可分配的成员列表
```bash
gitlink-cli issue +assigners --owner <owner> --repo <repo> --format json
```
返回仓库协作成员(含 `id``login`AI 据此推荐责任人。
> ⚠️ **个人仓库会返回空数组**`"assigners": [], "total_count": 0`。GitLink 的 `/issue_assigners` 只返回具有**显式项目角色**的成员collaborator/manager 等),**不隐式包含 owner**。空数组不是命令失败,而是真实场景——此时按下方"列表为空"分支处理。
### Step 2AI 推荐责任人
按 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 <owner> --repo <repo> --number <n> --format json
# Step 3bPATCH 分配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/<owner>/<repo>/issues/<n> --body '{
"subject": "< subject 原样回传>",
"description": "< description 原样回传>",
"assigned_to_id": <user_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 的报告中要如实告知用户:"已分配给 XX 是否收到通知需 X 本人 `notification +list --owner X` 自查"。
### Step 5输出分配结果
```markdown
## 👥 责任人分配报告 — <owner>/<repo>
| Issue | 分类 | 责任人 | 通知状态 |
|-------|:----:|--------|:------:|
| #12 | bug | @zhangqing | ✅ 已通知 |
| #13 | enhancement | @ylly | ✅ 已通知 |
> 责任人可在 GitLink 网页对应 Issue 页右侧"负责人"栏查看。
```
---
## 工作流 3批量分拣 + 报告
**触发场景:** "把仓库里所有没分类的 Issue 一次性处理掉"
### Step 1批量拉取未分类 Issue
```bash
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json
# AI 客户端过滤 tags 为空的 Issue
```
### Step 2对每个 Issue 执行"工作流 1 + 工作流 2"
依次分类打标签 → 推荐并分配责任人。**批量写入前先给用户一份预览清单**,得到确认后再批量执行。
### Step 3生成分拣总报告
```markdown
## 📋 Issue 智能分拣总报告 — <owner>/<repo>
📅 处理时间:<YYYY-MM-DD HH:MM>
🎯 处理范围:所有开放且未分类的 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/<owner>/<repo>/issues
- 标签视图https://gitlink.org.cn/<owner>/<repo>/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/<owner>/<repo>/issues/<n> --body '{
"subject": "<原标题>", "description": "<原描述>", "assigned_to_id": <user_id>
}'
# 查询可分配成员
gitlink-cli issue +assigners --owner <owner> --repo <repo> --format json
# 查询某用户的通知(--owner 填该用户自己的 login
gitlink-cli notification +list --owner <assignee_login> --format json
# 把通知标记为已读(如需)
gitlink-cli notification +read --owner <assignee_login> --id <notification_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 <id>` 会替换原有标签。若 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 BashMSYS2会转换 `/` 开头的路径参数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/<login>/messages`**GitLink 平台只允许用户查询自己的通知**,跨用户查询返回 `[403] 您没有权限进行该操作`实测zhangqing23 查 ylly 的通知被拒)。因此无法第三方代为验证通知到达,只能由责任人本人自查。
- **分配会自动触发通知:** GitLink 平台在 `assigned_to_id` 变更时会自动给被分配人发站内消息,**无需也不存在** "send notification" 命令。`notification +list` 只用于**验证**通知已生成(且只能自验证)。
- **`assigners` 字段两个位置:** Issue 对象里 `assigners` 是已分配人列表(数组),`issue +assigners` 命令返回的是**可分配的候选人**列表。两者不同,别混淆。
- **`--number` 是网页编号:** 用 GitLink 网页 URL 中显示的编号(`/issues/<n>`),不是数据库主键。
- **不要和 `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 条建议先按更新时间排序

View File

@ -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 字段映射

View File

@ -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 | 响应以 `<!doctype html>` 开头 | 去掉路径前导 `/` 重试 |
| 未读通知 > 返回条数 | `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

View File

@ -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 <id1>,<id2>,<id3> --dry-run --format json
# 标记单条已读
gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json
# 确认执行
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --yes --format json
# 预览将全部未读系统通知标记为已读
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
# 批量标记已读 — 逐条调用GitLink 暂无批量已读 API
for id in <id1> <id2> <id3>; 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 <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"`

View File

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

View File

@ -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": "<base64编码的内容>",
"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":"<base64>","branch":"dev","message":"add file"}'
gitlink-cli repo +create-file --body '{"filepath":"file.md","content":"<base64>","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":"<base64>","sha":"<sha>","branch":"dev","message":"update file"}'
gitlink-cli repo +update-file --body '{"filepath":"file.md","content":"<base64>","sha":"<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
```
## 注意事项

View File

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

View File

@ -49,7 +49,7 @@ gitlink-cli release +list --owner <owner> --repo <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 <owner> --repo <repo> --format json
gitlink-cli release +list --owner <owner> --repo <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 NotesAI 分析提交后组织内容)

View File

@ -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 <commit_sha>
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus --sha <commit_sha> --file src/main.go
gitlink-cli repo +commit-diff --owner Gitlink --repo forgeplus --sha <commit_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)

View File

@ -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 <owner> --repo <repo> --format json
| **代码规模** | `size` | 粗略判断项目复杂度 |
| **开放性** | `forked_count` | fork 数反映二次开发热度 |
| **PR 活跃度** | `pull_requests_count` | 反映代码贡献频率 |
| **代码活跃度**v1.1 | `search +code` 命中量 | 反映技术在实际代码中的使用程度 |
可选补充(如有需要):
@ -98,33 +129,29 @@ gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json
gitlink-cli release +list --owner <owner> --repo <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 <owner> --repo <repo> --format json
- **成熟信号**:大量 watcher + 稳定 Release 节奏 + 大社区 → 技术趋于成熟
- **衰退信号**:超过 180 天无更新 + 少量 contributor + 无新 Release → 可能已不活跃
- **新兴信号**:小社区 + 快速迭代 + 最新更新时间近 → 可能是新兴项目
- **代码证据**v1.1`search +code` 命中量增长 → 技术采纳度上升
### Step 5生成技术调研报告
@ -144,7 +172,8 @@ gitlink-cli release +list --owner <owner> --repo <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 <owner> --repo <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 <owner> --repo <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 <owner> --repo <repo> --format json
## 六、数据来源
所有数据通过 `gitlink-cli` 从 GitLink 平台实时获取,每个项目均已通过 `repo +info` 验证。
搜索维度:`search +repos`(仓库)、`search +code`(代码)、`search +issues`Issue
```
---
@ -231,13 +256,14 @@ gitlink-cli release +list --owner <owner> --repo <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 <owner> --repo <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 平台**,结果可能偏向中文技术生态

View File

@ -34,7 +34,7 @@ gitlink-cli auth logout
- GitLink Token 有效期 **7 天**,过期需重新登录
- Token 存储在 OS KeychainmacOS 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 <domain> +<verb>` | `gitlink-cli repo +info` | 高频操作,推荐优先使用 |
| Raw API | `gitlink-cli api <METHOD> <PATH>` | `gitlink-cli api GET /users/me` | Shortcuts 未覆盖的接口 |
| Raw API 批处理 | `gitlink-cli api --batch-file <file>` | `gitlink-cli api --batch-file plan.json --dry-run` | 对未封装接口做可审计的批量自动化 |
| Raw API | `gitlink-cli api <METHOD> <PATH>` | `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": "<base64编码>",
"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": "<base64编码>",
"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": "<sha>",
"branch": "master",

View File

@ -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
# ⚠️ 路径不要以 / 开头,否则会被解析为本地文件路径
```

View File

@ -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 <command> --debug
### 查看完整 API 请求
```bash
gitlink-cli api GET /users/me --debug
gitlink-cli user +me --debug
```
### 检查认证状态

View File

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

View File

@ -28,7 +28,7 @@ gitlink-cli issue +list --state open --format json
gitlink-cli issue +view --id <issue_id> --format json
# 3. 根据内容分析,通过 Raw API 添加标签
gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>]}'
gitlink-cli issue +update --number '{"issue_tag_ids":[<tag_id>]}'
```
**分类规则建议**
@ -44,40 +44,31 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>
# 1. 获取 PR 详情
gitlink-cli pr +view --id <pr_id> --format json
# 2. 获取完整审查上下文仓库、PR、变更文件、Review、Issue、标签
gitlink-cli workflow +review-context --number <pr_id> --format json
# 2. 获取变更文件列表
gitlink-cli pr +files --id <pr_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 <pr_id> --format json
# 4. 添加 Review 评论
gitlink-cli pr +review --body '{"body":"代码审查意见...","event":"COMMENT"}'
```
## 工作流 3Release 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便于审计和复现。
## 工作流 4Repo 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