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:
commit
b8589dc93c
|
|
@ -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
|
||||
|
|
@ -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 ./...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 就行
|
||||
}
|
||||
|
|
|
|||
128
cmd/root.go
128
cmd/root.go
|
|
@ -1,55 +1,133 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse"
|
||||
statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
type RootOptions struct {
|
||||
Version string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
ConfigLang string
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||
if tr == nil {
|
||||
var err error
|
||||
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(newDoCmd())
|
||||
version := opts.Version
|
||||
if version == "" {
|
||||
version = Version
|
||||
}
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: tr.T("cmd.root.short"),
|
||||
Long: tr.T("cmd.root.long"),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
|
||||
rootCmd.AddCommand(newVersionCmd(version, tr))
|
||||
rootCmd.AddCommand(aliasCmd.NewAliasCmd())
|
||||
rootCmd.AddCommand(browseCmd.NewBrowseCmd())
|
||||
rootCmd.AddCommand(statusCmd.NewStatusCmd())
|
||||
|
||||
shortcuts.RegisterAll(rootCmd, tr)
|
||||
|
||||
if opts.Args != nil {
|
||||
rootCmd.SetArgs(opts.Args)
|
||||
}
|
||||
return rootCmd, nil
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("gitlink-cli %s\n", Version)
|
||||
},
|
||||
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: tr.T("cmd.version.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
rootCmd, err := NewRootCmd(RootOptions{
|
||||
Version: Version,
|
||||
Args: args,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
|
||||
available, err := i18n.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env == nil {
|
||||
env = i18n.EnvMap()
|
||||
}
|
||||
if configLang == "" {
|
||||
configLang = loadConfigLangBestEffort()
|
||||
}
|
||||
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
|
||||
ExplicitLang: i18n.PreScanLang(args),
|
||||
Env: env,
|
||||
ConfigLang: configLang,
|
||||
}, available)
|
||||
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
|
||||
tr := i18n.Default()
|
||||
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
|
||||
}
|
||||
return i18n.New(i18n.Options{Locale: resolved.Locale})
|
||||
}
|
||||
|
||||
func loadConfigLangBestEffort() string {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Lang
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 包
|
||||
|
|
@ -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 域组 + 前缀添加。
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 验证安装"
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 未找到,请确认仓库是否存在")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 未找到,请确认仓库是否存在")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
# gitlink-ci-health 使用样例
|
||||
|
||||
## 样例 1:CI 未激活的仓库
|
||||
|
||||
**日期**:2026-06-03
|
||||
**仓库**:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli)
|
||||
**CLI 版本**:gitlink-cli 0.1.18
|
||||
|
||||
### 执行流程
|
||||
|
||||
```bash
|
||||
# Step 1: 检查 CI 状态(方法 1 — repo +info)
|
||||
gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json
|
||||
# → "open_devops": false ← CI 未激活
|
||||
|
||||
# Step 1 补充(方法 2 — ci +builds)
|
||||
gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json
|
||||
# → {"status":-1,"message":"接口数据异常"} ← 确认 CI 未激活
|
||||
|
||||
# 此时终止后续步骤,生成"CI 未激活"报告
|
||||
```
|
||||
|
||||
### 关键发现
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| `open_devops` | `false` |
|
||||
| `ci +builds` 返回 | `{"status": -1, "message": "接口数据异常"}` |
|
||||
| 可用 CI 命令 | `+builds`、`+logs`、`+restart`、`+stop` |
|
||||
| 不存在的命令 | `ci +authorize`、`ci +activate`、`ci +deactivate` |
|
||||
|
||||
### 诊断结论
|
||||
|
||||
CI 完全未启用,需通过 GitLink Web 界面开启(仓库设置 → DevOps)。用户拥有 Manager 权限,可以操作。
|
||||
|
||||
### 生成的报告
|
||||
|
||||
```markdown
|
||||
# 🔧 CI 健康巡检报告:jiangtx/gitlink-cli
|
||||
|
||||
> 巡检时间:2026-06-03
|
||||
> 仓库:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli)
|
||||
> CI 状态:❌ 未激活
|
||||
|
||||
## 一、健康度总览
|
||||
|
||||
| 指标 | 数值 | 评分 |
|
||||
|------|------|------|
|
||||
| CI 激活状态 | ❌ 未激活(open_devops: false) | 0/4 |
|
||||
| 整体成功率 | N/A | —/5 |
|
||||
| 近期稳定性 | N/A | —/5 |
|
||||
| 构建频率 | N/A | —/3 |
|
||||
| 修复速度 | N/A | —/3 |
|
||||
| **总分** | | **0/20** |
|
||||
|
||||
## 二、诊断详情
|
||||
|
||||
API 调用 ci +builds 返回:
|
||||
{"status": -1, "message": "接口数据异常"}
|
||||
|
||||
仓库元数据显示 open_devops: false,确认该仓库尚未启用 GitLink 平台的 CI/CD(DevOps)服务。
|
||||
|
||||
## 三、改进建议
|
||||
|
||||
- 🔴 立即激活 CI:前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务
|
||||
- 🟡 配置 CI Pipeline:建议添加 .gitlink-ci.yml 配置编译和测试流水线
|
||||
|
||||
## 四、仓库基本信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 默认分支 | master |
|
||||
| 仓库大小 | 13.4 MB |
|
||||
| 贡献者 | 2 |
|
||||
| PR 数量 | 9 |
|
||||
| 权限 | Manager |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异常场景速查
|
||||
|
||||
| 场景 | 检测方式 | `ci +builds` 返回值 | 处理 |
|
||||
|------|----------|---------------------|------|
|
||||
| CI 未激活 | `repo +info` 的 `open_devops: false` | `{"status":-1,"message":"接口数据异常"}` | 建议 Web 界面激活,终止巡检 |
|
||||
| CI 已激活但无构建 | `repo +info` 的 `open_devops: true` + builds 为空 | `[]` 或空列表 | 标注"暂无构建记录" |
|
||||
| 构建样本不足(<5) | builds 列表长度 < 5 | 正常 JSON 数组 | 标注"数据有限,不具代表性" |
|
||||
|
||||
---
|
||||
|
||||
## 版本兼容性说明
|
||||
|
||||
本 skill 基于 `gitlink-cli 0.1.18` 编写。不同版本的 CI 子命令可能有差异:
|
||||
|
||||
| CLI 版本 | 可用 CI 命令 |
|
||||
|----------|-------------|
|
||||
| 0.1.18 | `+builds`、`+logs`、`+restart`、`+stop` |
|
||||
| 未来版本 | 可能新增 `+activate`、`+deactivate` 等 |
|
||||
|
||||
当 CLI 版本更新后,重新验证可用命令:
|
||||
```bash
|
||||
gitlink-cli ci --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 样例 2:通过 Agent 调用 Skill(自动巡检)
|
||||
|
||||
**日期**:2026-06-03
|
||||
**仓库**:jiangtx/gitlink-cli
|
||||
**调用方式**:`Agent(subagent_type="general-purpose", prompt="调用 gitlink-ci-health skill,检查 jiangtx/gitlink-cli 的 CI 状态。严格按照 skill 的工作流步骤执行。")`
|
||||
|
||||
### Agent 自主执行的命令序列
|
||||
|
||||
```
|
||||
工具调用 1: gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json
|
||||
→ open_devops: false ← 发现 CI 未激活
|
||||
|
||||
工具调用 2: gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json
|
||||
→ {"status": -1, "message": "接口数据异常"} ← 二次确认
|
||||
```
|
||||
|
||||
### Agent 决策过程
|
||||
|
||||
Agent 读取到 `open_devops: false` 后,**正确遵循了 skill 的异常场景处理规则**:
|
||||
|
||||
> "CI 未激活 → 报告 CI 状态为'未激活',建议通过 Web 界面开启,**不再继续后续步骤**"
|
||||
|
||||
Agent 没有继续尝试 `ci +logs` 或统计分析,直接生成了完整报告。共消耗 **5 次工具调用**,**32,113 tokens**,耗时 **35.8 秒**。
|
||||
|
||||
### Agent 生成的报告
|
||||
|
||||
```markdown
|
||||
# CI 健康巡检报告:gitlink-cli
|
||||
|
||||
> 巡检时间:2026-06-03
|
||||
> 仓库:jiangtx/gitlink-cli
|
||||
> CI 状态:未激活
|
||||
|
||||
## 一、健康度总览
|
||||
|
||||
| 指标 | 数值 | 评分 |
|
||||
|------|------|------|
|
||||
| CI 激活状态 | 未激活 | 0/4 |
|
||||
| 整体成功率 | N/A | -/5 |
|
||||
| 近期稳定性 | N/A | -/5 |
|
||||
| 构建频率 | N/A | -/3 |
|
||||
| 修复速度 | N/A | -/3 |
|
||||
| **总分** | | **0/20** |
|
||||
|
||||
## 二、构建趋势
|
||||
|
||||
无构建记录。仓库 CI 未启用。
|
||||
|
||||
## 三、故障分析
|
||||
|
||||
CI 未激活,无构建数据可供分析。
|
||||
|
||||
## 四、改进建议
|
||||
|
||||
- **立即激活 CI**:前往 GitLink Web 界面 → 仓库设置 → DevOps,开启 CI/CD 服务。
|
||||
(CLI 暂不支持 ci +activate 命令,需要通过 Web 界面操作。)
|
||||
|
||||
---
|
||||
|
||||
总结:仓库 jiangtx/gitlink-cli(项目 ID: 1547588,默认分支: master,权限: Manager)
|
||||
当前 open_devops 字段为 false,CI/CD 服务未开启。其他仓库信息:2 位贡献者、
|
||||
9 个 PR、0 个 Issue,Fork 自 Gitlink/gitlink-cli。
|
||||
```
|
||||
|
||||
### 验证结论
|
||||
|
||||
✅ skill v1.1.0 修复验证通过:
|
||||
- Agent 正确使用了 `repo +info` 的 `open_devops` 字段判断 CI 状态
|
||||
- Agent 在 CI 未激活时正确终止,没有执行后续无效步骤
|
||||
- Agent 没有尝试调用不存在的 `ci +authorize` 或 `ci +activate`
|
||||
- Agent 正确建议通过 Web 界面激活
|
||||
- 报告结构完整,包含了仓库基本信息
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
---
|
||||
name: gitlink-ci-health
|
||||
version: 1.1.0
|
||||
description: "CI 健康巡检:检查仓库 CI/CD 授权状态、构建历史和成功率,生成 CI 健康度报告。当用户需要检查 CI 状态、分析构建成功率、排查 CI 故障时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli ci --help"
|
||||
---
|
||||
|
||||
# gitlink-ci-health(CI 健康巡检)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 本 Skill 为只读操作。CI 激活/关闭需通过 GitLink Web 界面操作,CLI 不提供对应命令。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
面向维护者的 CI/CD 健康度巡检工具:
|
||||
|
||||
1. **授权检查** — 确认仓库 CI 是否已激活
|
||||
2. **构建历史** — 获取近期构建列表
|
||||
3. **成功率统计** — 计算构建成功率和平均耗时
|
||||
4. **故障分析** — 识别频繁失败的构建及其原因
|
||||
5. **健康报告** — 生成 CI 健康度评分和改进建议
|
||||
|
||||
---
|
||||
|
||||
## 工作流:CI 健康巡检
|
||||
|
||||
### Step 1:检查 CI 授权状态
|
||||
|
||||
**方法 1(推荐)**:通过 `repo +info` 查看 `open_devops` 字段:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <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次全部成功=5,8-9次=4,6-7次=3,4-5次=2,<4次=1 |
|
||||
| 构建频率 | 3 | 每天有构建=3,2-3天=2,每周=1,更少=0 |
|
||||
| 修复速度 | 3 | 失败后1次内修复=3,2-3次=2,>3次=1 |
|
||||
|
||||
### Step 5:生成 CI 健康报告
|
||||
|
||||
---
|
||||
|
||||
## 输出模板
|
||||
|
||||
```markdown
|
||||
# 🔧 CI 健康巡检报告:{{仓库名}}
|
||||
|
||||
> 巡检时间:{{当前时间}}
|
||||
> 仓库:{{full_name}}
|
||||
> CI 状态:{{ci_status_display}}
|
||||
|
||||
---
|
||||
|
||||
## 一、健康度总览
|
||||
|
||||
| 指标 | 数值 | 评分 |
|
||||
|------|------|------|
|
||||
| CI 激活状态 | {{activated_status}} | {{activate_score}}/4 |
|
||||
| 整体成功率 | {{success_rate}}%({{success_count}}/{{total_count}}) | {{success_score}}/5 |
|
||||
| 近期稳定性 | 近 10 次 {{recent_success}} 次成功 | {{stability_score}}/5 |
|
||||
| 构建频率 | {{build_frequency_desc}} | {{frequency_score}}/3 |
|
||||
| 修复速度 | {{repair_speed_desc}} | {{repair_score}}/3 |
|
||||
| **总分** | | **{{total_score}}/20** |
|
||||
|
||||
## 二、构建趋势
|
||||
|
||||
```
|
||||
最近 20 次构建:
|
||||
✅✅❌✅✅✅❌✅✅✅✅✅❌✅✅✅✅✅✅
|
||||
(✅=成功 ❌=失败)
|
||||
```
|
||||
|
||||
| 时间段 | 总构建 | 成功 | 失败 | 成功率 |
|
||||
|--------|--------|------|------|--------|
|
||||
| 最近 7 天 | {{w1_total}} | {{w1_success}} | {{w1_fail}} | {{w1_rate}}% |
|
||||
| 7-14 天 | {{w2_total}} | {{w2_success}} | {{w2_fail}} | {{w2_rate}}% |
|
||||
| 14-30 天 | {{w3_total}} | {{w3_success}} | {{w3_fail}} | {{w3_rate}}% |
|
||||
|
||||
## 三、故障分析
|
||||
|
||||
> 如无失败构建,输出:**🎉 分析期内无失败构建,CI 运行健康。**
|
||||
|
||||
| 构建 ID | 分支 | 失败时间 | 错误摘要 |
|
||||
|---------|------|----------|----------|
|
||||
| {{id}} | {{branch}} | {{time}} | {{error_summary}} |
|
||||
|
||||
### 故障模式分类
|
||||
|
||||
| 故障类型 | 次数 | 占比 |
|
||||
|----------|------|------|
|
||||
| 编译错误 | {{compile_count}} | {{compile_pct}}% |
|
||||
| 测试失败 | {{test_fail_count}} | {{test_fail_pct}}% |
|
||||
| 超时 | {{timeout_count}} | {{timeout_pct}}% |
|
||||
| 环境问题 | {{env_count}} | {{env_pct}}% |
|
||||
| 其他 | {{other_count}} | {{other_pct}}% |
|
||||
|
||||
## 四、改进建议
|
||||
|
||||
<!-- 根据分析结果,从以下列表中选择匹配的建议输出 -->
|
||||
|
||||
- **立即激活 CI**(当 CI 未激活时):前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务(CLI 暂不支持 `ci +activate`)
|
||||
- **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因
|
||||
- **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI
|
||||
- **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异常场景处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| CI 未激活 | 报告 CI 状态为"未激活",建议通过 Web 界面开启,不再继续后续步骤 |
|
||||
| 无构建记录 | 标注"仓库暂无 CI 构建记录" |
|
||||
| `ci +logs` 返回空 | 标注"日志不可用" |
|
||||
| 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ✅ **所有命令使用 `--format json`**,确保可解析
|
||||
- ✅ **CI 激活/关闭需通过 GitLink Web 界面**,CLI 不提供 `+activate`/`+deactivate` 命令
|
||||
- ✅ **Owner/repo 优先从 `git remote` 自动解析**
|
||||
- ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行
|
||||
- ⚠️ **构建历史无分页参数**,实际返回条数取决于 API
|
||||
- ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务
|
||||
- ⚠️ **`repo +info` 的 `open_devops` 字段**是判断 CI 是否激活的最可靠方式
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
## 代码审查最佳实践
|
||||
|
|
|
|||
|
|
@ -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"}'
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 质量检查清单
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 默认输出到 stdout(JSON)
|
||||
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
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) 包含原始命令输出数据
|
||||
|
|
|
|||
|
|
@ -130,9 +130,7 @@ PR 详细列表:
|
|||
}
|
||||
```
|
||||
|
||||
### 5. 不可用的命令(gitlink-cli 0.1.18 历史记录)
|
||||
|
||||
> 当前版本已新增 `user +heatmap`、`user +stats`、`user +trends`。下表仅记录本样例在 0.1.18 上的历史执行结果。
|
||||
### 5. 不可用的命令
|
||||
|
||||
| 命令 | 结果 |
|
||||
|------|------|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
|
|
|||
|
|
@ -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:生成周报
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: gitlink-issue-triage
|
||||
version: 1.0.0
|
||||
description: "Issue 智能分拣:扫描未分类 Issue,AI 按语义/关键词自动分类打标签、推荐并分配责任人,再用 notification 验证通知到位,最后批量产出分拣报告。当用户需要治理堆积 Issue、自动打标签、分配负责人或检查通知状态时触发。"
|
||||
version: 1.1.0
|
||||
description: "Issue 智能分拣:自动分析仓库 Issue 列表,按类型、紧急度、复杂度分类,生成分拣报告和维护建议。当用户需要整理 Issue、分类 Issue、Issue 分拣、Issue 优先级排序时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
|
|
@ -11,274 +11,249 @@ metadata:
|
|||
# gitlink-issue-triage(Issue 智能分拣)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有写入/删除操作前(打标签、分配责任人、改状态),务必先确认用户意图。**
|
||||
**CRITICAL — `issue +series-update` 为写操作,会批量修改 Issue 状态。执行前需确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
---
|
||||
|
||||
## 工作流概览
|
||||
## 功能概述
|
||||
|
||||
| 工作流 | 操作 | AI Agent 角色 | 写入 |
|
||||
|--------|------|--------------|:----:|
|
||||
| 工作流 1:自动分类打标签 | 扫描未分类 Issue → AI 判断类型 → 查/建标签 → 打标签 | 语义分类 + 标签创建 | 是 |
|
||||
| 工作流 2:自动分配 + 通知 | 推荐责任人 → 分配 → 用 notification 验证通知 | 责任人推荐 | 是 |
|
||||
| 工作流 3:批量分拣 + 报告 | 一次性处理所有未分类 Issue → 汇总报告 | 批处理 + 报告生成 | 是 |
|
||||
对仓库的开放 Issue 进行全量扫描和智能分类,输出结构化的分拣报告:
|
||||
|
||||
1. **类型分类** — 判断每个 Issue 是 Bug、功能请求、文档问题还是使用咨询
|
||||
2. **紧急度评估** — 根据关键词和优先级字段标注紧急程度
|
||||
3. **复杂度预估** — 根据描述详尽程度评估修复难度
|
||||
4. **活动日志分析** — 通过 `issue +journals` 查看 Issue 活动历史
|
||||
5. **行动建议** — 给出具体处理建议(立即修复/需讨论/可关闭/适合作入门任务)
|
||||
6. **批量操作** — 支持通过 `issue +series-update` 批量更新 Issue 状态
|
||||
|
||||
---
|
||||
|
||||
## 分类规则表
|
||||
## 工作流:Issue 全量分拣
|
||||
|
||||
AI 读 Issue 标题 + 描述后按以下规则分类(关键词只是辅助,**最终以语义为准**,能识别关键词未覆盖的同义表述):
|
||||
### Step 1:获取项目概览
|
||||
|
||||
| Issue 关键词 / 语义 | 推荐标签 | 颜色 | 优先级 |
|
||||
|---------------------|---------|------|:------:|
|
||||
| bug / 错误 / 失败 / crash / 异常 / 报错 | bug | `#ee0701` | 🔴 高 |
|
||||
| feature / 新增 / 建议 / 希望 / 能否支持 | enhancement | `#84b6eb` | 🔵 低 |
|
||||
| 安全 / 漏洞 / 权限 / 泄露 / 注入 / XSS | security | `#b60205` | 🔴 高 |
|
||||
| 性能 / 慢 / 卡顿 / 优化 / 内存 / OOM | performance | `#fbca04` | 🟡 中 |
|
||||
| 文档 / README / 注释 / 示例 / 拼写 | documentation | `#0075ca` | 🔵 低 |
|
||||
| question / 如何 / 怎么 / 请问 / ? | question | `#cc317c` | 🟡 中 |
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <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 2:AI 筛选"未分类"Issue
|
||||
> ⚠️ **已知问题**:`--state open` 过滤不准确,返回列表可能包含已关闭的 Issue。需在客户端按 `status_id` 二次过滤:保留 `status_id` = 1(新增)或 2(正在解决),排除 3(已解决)、5(关闭)。`status_id` = 0 纳入分析但标注"状态异常"。
|
||||
|
||||
从返回结果中筛选出 `tags` 字段为空数组 `[]` 或缺失的 Issue(即没有任何标签)。已在 `gitlink-onboarding` 标过 `good first` 的 Issue 跳过,避免重复干预。
|
||||
|
||||
> 字段说明:`issue +list` 返回的 Issue 对象里,标签字段名是 **`tags`**(注意 `label +list` 用的是 `issue_tags`,两者不同)。每个 Issue 的 `number` 是网页 URL 显示的编号。
|
||||
|
||||
### Step 3:逐个读取详情用于分类
|
||||
如果返回数量 >20,追加分页参数获取全部:
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner <owner> --repo <repo> --number <n> --format json
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json --page 2
|
||||
```
|
||||
|
||||
### Step 4:AI 按分类规则表判断类型
|
||||
### 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 新增)
|
||||
|
||||
对高优先级 Issue(urgent/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(🔵 低)
|
||||
- 兜底 triage:0 个
|
||||
## 总览
|
||||
|
||||
| 指标 | 数量 |
|
||||
|------|------|
|
||||
| Bug | {{bug_count}} |
|
||||
| 功能请求 | {{feature_count}} |
|
||||
| 文档 | {{docs_count}} |
|
||||
| 咨询 | {{question_count}} |
|
||||
| 元讨论 | {{meta_count}} |
|
||||
| 其他 | {{other_count}} |
|
||||
| **需立即处理** | {{urgent_count}} |
|
||||
| **适合入门** | {{good_first_issue_count}} |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 需立即处理
|
||||
|
||||
> 如本段为空,输出:*当前无紧急 Issue,状态健康。*
|
||||
|
||||
| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 |
|
||||
|---|------|------|--------|--------|----------|------|------|
|
||||
| {{number}} | {{subject}} | bug | urgent | medium | {{last_journal_time}} | fix-now | |
|
||||
| ... | ... | ... | ... | ... | ... | ... | ... |
|
||||
|
||||
## 🟡 建议近期处理
|
||||
|
||||
| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 |
|
||||
|---|------|------|--------|--------|----------|------|------|
|
||||
| ... | ... | bug/feature | high/normal | easy/medium | ... | investigate/implement | |
|
||||
|
||||
## 🟢 可延迟 / 需讨论
|
||||
|
||||
| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 |
|
||||
|---|------|------|--------|--------|----------|------|------|
|
||||
| ... | ... | question/feature | normal/low | medium/hard | ... | discuss | |
|
||||
|
||||
## ⭐ 适合入门(Good First Issue)
|
||||
|
||||
| # | 标题 | 类型 | 复杂度 | 推荐理由 |
|
||||
|---|------|------|--------|----------|
|
||||
| {{number}} | {{subject}} | bug/docs | easy | 范围明确,单文件修改 |
|
||||
|
||||
## ⚠️ 候选关闭(90+ 天无活动)
|
||||
|
||||
| # | 标题 | 最后更新 | 建议 |
|
||||
|---|------|----------|------|
|
||||
| {{number}} | {{subject}} | {{updated_at}} | 评论询问是否仍需要,如无回应可关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 维护建议
|
||||
|
||||
1. **立即行动**:{{urgent_count}} 个紧急 Issue 需要优先处理
|
||||
2. **本周目标**:建议处理 {{suggested_this_week}} 个 Issue
|
||||
3. **社区引导**:{{good_first_issue_count}} 个 Issue 适合标记为 good first issue
|
||||
4. **清理计划**:{{close_candidate_count}} 个 Issue 长期无活动,建议批量关闭
|
||||
5. {{#if no_tags}}本仓库未使用 Issue 标签系统,建议建立标签体系{{/if}}
|
||||
6. {{#if status_anomalies}}本批次有 {{status_anomaly_count}} 个 Issue 状态异常(status_id=0){{/if}}
|
||||
|
||||
### 批量操作建议
|
||||
|
||||
> 如无适用操作,输出:*当前无需批量操作。*
|
||||
|
||||
以下 Issue 建议批量关闭(已确认超 90 天无活动):
|
||||
`gitlink-cli issue +series-update --owner <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 2:AI 推荐责任人
|
||||
|
||||
按 Issue 类型与成员专长做匹配(无成员画像时按公平轮询/按 Issue 类型分组):
|
||||
|
||||
| Issue 类型 | 推荐策略 |
|
||||
|-----------|---------|
|
||||
| bug / security | 优先派给最近修过相关模块的成员 |
|
||||
| documentation | 任意有空闲的成员 |
|
||||
| question | 仓库 owner 或 maintainer |
|
||||
| performance | 核心开发成员 |
|
||||
|
||||
如可分配列表为空(个人仓库、无 collaborator),跳过分配并在报告中标注"无可分配成员"。
|
||||
|
||||
### Step 3:分配责任人(Raw API)
|
||||
|
||||
> ⚠️ `issue +update` 当前不支持 `--assignee`(见下方"已知限制"),分配必须走 Raw API PATCH,并保留原 `subject`/`description`。
|
||||
|
||||
```bash
|
||||
# Step 3a:先 GET 拿到当前 subject 和 description(避免被清空)
|
||||
gitlink-cli issue +view --owner <owner> --repo <repo> --number <n> --format json
|
||||
|
||||
# Step 3b:PATCH 分配(assigned_to_id 用 assigners 返回的用户 id)
|
||||
# ⚠️ Git Bash 用户必须加 MSYS_NO_PATHCONV=1 前缀,否则 / 开头路径会被 MSYS2 转成
|
||||
# Windows 路径(debug 实测:/v1/... 变成 /api/F:/Git/Git/v1/...),导致 404
|
||||
# 注:api 命令会自动补 .json 后缀,路径无需手动加(已实测确认)
|
||||
MSYS_NO_PATHCONV=1 gitlink-cli api PATCH /v1/<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 的报告中要如实告知用户:"已分配给 X,X 是否收到通知需 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 Bash(MSYS2)会转换 `/` 开头的路径参数(PATCH 404 的真正原因):** 以 `/` 开头的 Raw API 路径(如 `/v1/owner/repo/issues/9`)会被 Git Bash 自动转成 Windows 路径(debug 实测:`/v1/...` 被改成 `/api/F:/Git/Git/v1/...`),请求 URL 错误、返回 404。**这是本机 PATCH 失败的唯一原因,与 .json 无关**(`api` 命令会自动补 `.json` 后缀,已用 `--debug` 实测确认:不带 `.json` 的请求最终 URL 仍是 `.../issues/9.json`)。**解决:命令前加 `MSYS_NO_PATHCONV=1`**(实测 `MSYS_NO_PATHCONV=1 gitlink-cli api PATCH /v1/.../issues/9 ...` 返回 `ok:true`);或路径用双斜杠 `//v1/...`。cmd.exe 无此路径转换问题(PowerShell 的坑是引号,见上一条)。
|
||||
- **`assigned_to_id` 用数字 ID:** 不是 login 字符串。从 `issue +assigners` 返回里取 `id` 字段。⚠️ **实测:个人仓库 `assigners` 为空时,用 owner user_id 兜底分配也不生效**——GitLink 校验 `assigned_to_id` 必须在 assigners 候选列表内,PATCH 虽返回 `ok:true` 但 `assigned_to` 仍为空。个人仓库需先 `member +add` 添加 collaborator 才能分配,否则跳过分配并在报告标注"无可分配成员"。
|
||||
- **notification +list 是自查询限定:** 该命令查 `/users/<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 条建议先按更新时间排序
|
||||
|
|
|
|||
|
|
@ -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 字段映射
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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 Notes(AI 分析提交后组织内容)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 平台**,结果可能偏向中文技术生态
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ gitlink-cli auth logout
|
|||
|
||||
- GitLink Token 有效期 **7 天**,过期需重新登录
|
||||
- Token 存储在 OS Keychain(macOS Keychain / Linux Secret Service / Windows Credential Manager)
|
||||
- Fallback 存储:`$GITLINK_CONFIG_DIR/credentials`(未设置时为 `~/.config/gitlink-cli/credentials`)
|
||||
- Fallback 存储:`~/.config/gitlink-cli/credentials`
|
||||
|
||||
### 认证错误处理
|
||||
|
||||
|
|
@ -63,27 +63,6 @@ gitlink-cli auth login
|
|||
- HTTPS: `https://www.gitlink.org.cn/owner/repo.git`
|
||||
- SSH: `git@www.gitlink.org.cn:owner/repo.git`
|
||||
|
||||
## Shell 自动补全
|
||||
|
||||
安装后可以按用户当前 shell 生成补全脚本,帮助用户发现 Shortcut 子命令和参数:
|
||||
|
||||
```bash
|
||||
# Bash
|
||||
gitlink-cli completion bash > ~/.local/share/bash-completion/completions/gitlink-cli
|
||||
|
||||
# Zsh
|
||||
gitlink-cli completion zsh > "${fpath[1]}/_gitlink-cli"
|
||||
|
||||
# Fish
|
||||
gitlink-cli completion fish > ~/.config/fish/completions/gitlink-cli.fish
|
||||
|
||||
# PowerShell
|
||||
gitlink-cli completion powershell > gitlink-cli.ps1
|
||||
. ./gitlink-cli.ps1
|
||||
```
|
||||
|
||||
如果终端补全不需要描述文本,可以追加 `--no-descriptions`。
|
||||
|
||||
## 输出格式
|
||||
|
||||
所有命令输出遵循统一 Envelope 格式:
|
||||
|
|
@ -111,8 +90,7 @@ gitlink-cli completion powershell > gitlink-cli.ps1
|
|||
| 层级 | 格式 | 示例 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| Shortcuts | `gitlink-cli <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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# ⚠️ 路径不要以 / 开头,否则会被解析为本地文件路径
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
### 检查认证状态
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"}'
|
||||
```
|
||||
|
||||
## 工作流 3:Release Notes 生成
|
||||
|
||||
**场景**:从提交历史和 PR 信号自动生成版本发布说明,适合维护者发版前检查、Agent 生成 changelog 草稿、竞赛材料展示等场景。
|
||||
**场景**:从提交历史自动生成版本发布说明。
|
||||
|
||||
```bash
|
||||
# 只读远程模式:从 GitLink compare 数据生成 Markdown
|
||||
gitlink-cli workflow +release-notes \
|
||||
--owner Gitlink \
|
||||
--repo gitlink-cli \
|
||||
--from-ref v1.1.0 \
|
||||
--to-ref master \
|
||||
--version v1.2.0 \
|
||||
--format markdown
|
||||
# 1. 获取两个版本之间的提交
|
||||
gitlink-cli repo +compare --format json
|
||||
|
||||
# 本地 JSON 模式:供 Agent 流水线、测试夹具或离线复现使用
|
||||
gitlink-cli workflow +release-notes \
|
||||
--from shortcuts/workflow/testdata/release_notes.json \
|
||||
--format json
|
||||
# 2. 获取已关闭的 Issue
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
|
||||
# 3. 生成 Release Notes 并创建发布
|
||||
gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 优先使用 `workflow +release-notes` 生成草稿,再由维护者决定是否创建 Release。
|
||||
- 使用 `--format json` 作为 Agent 间传递格式;使用 `--format markdown` 作为人类可读发布说明。
|
||||
- 远程模式只读取 compare 数据,不创建 Release,不评论、不打标签、不合并。
|
||||
- `--include-prs` 默认开启;当 compare 响应包含 PR 信号时会一起分类。
|
||||
- 分类规则是确定性的,不依赖 LLM API,便于审计和复现。
|
||||
|
||||
## 工作流 4:Repo Setup(仓库初始化)
|
||||
|
||||
**场景**:创建仓库并完成基础配置。
|
||||
|
|
@ -106,8 +97,8 @@ gitlink-cli issue +list --state closed --format json
|
|||
gitlink-cli pr +list --state open --format json
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# 3. 获取仓库工作流报告
|
||||
gitlink-cli workflow +repo-report --format json
|
||||
# 3. 获取项目动态
|
||||
gitlink-cli repo +activity --format json
|
||||
```
|
||||
|
||||
## Workflow: PR Summary (Read-only)
|
||||
|
|
@ -128,28 +119,6 @@ Rules:
|
|||
- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests.
|
||||
- Do not use LLM APIs for this workflow; it is rule-based and explainable.
|
||||
|
||||
## Workflow: Review Context (Read-only)
|
||||
|
||||
Use `workflow +review-context` when an Agent needs one deterministic JSON bundle for PR review or gatekeeping. It aggregates shortcut-backed read-only fetches for repository info, PR details, changed files, existing reviews, open issues, and labels.
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json
|
||||
|
||||
# Trim context for large repositories
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \
|
||||
--issue-limit 10 --label-limit 30 --format json
|
||||
|
||||
# Only fetch PR and changed files
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \
|
||||
--include-repo=false --include-reviews=false --include-issues=false --include-labels=false \
|
||||
--format json
|
||||
```
|
||||
|
||||
Rules:
|
||||
- This command is read-only and never comments, approves, rejects, merges, labels, or closes resources.
|
||||
- Prefer it before `workflow +pr-summary` when a review agent needs raw context plus existing review state.
|
||||
- The command records partial fetch failures in `notes` so Agents can proceed with available context.
|
||||
|
||||
## Workflow: Repo Report (Read-only)
|
||||
|
||||
Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report
|
||||
|
|
|
|||
Loading…
Reference in New Issue