diff --git a/.devops/build.yml b/.devops/build.yml new file mode 100644 index 0000000..bd2c0a5 --- /dev/null +++ b/.devops/build.yml @@ -0,0 +1,41 @@ +version: 2 +name: wyx_gitlink_cli_build +description: "master 分支收到合并后编译构建并验证新命令是否注册成功" +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: git clone + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: 编译并验证命令 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_pass: ((gitlink_cli.wyx_ssh_pass)) + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && echo '>>> 编译二进制' && go build -o gitlink-cli . && echo '>>> 验证根命令' && ./gitlink-cli --help | head -20 && echo '>>> 验证 pm 模块(6条命令)' && ./gitlink-cli pm --help && echo '>>> 验证 wiki 模块(5条命令)' && ./gitlink-cli wiki --help && echo '>>> 验证 export 模块(3条命令)' && ./gitlink-cli export --help && echo '>>> 验证 alias 命令' && ./gitlink-cli alias --help && echo '>>> 验证 browse 命令' && ./gitlink-cli browse --help && echo '>>> 验证 status 命令' && ./gitlink-cli status && echo '✅ 所有命令验证通过'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.devops/ci.yml b/.devops/ci.yml index 5acd27d..f8a9834 100644 --- a/.devops/ci.yml +++ b/.devops/ci.yml @@ -1,6 +1,6 @@ version: 2 -name: gitlink_cli_ci -description: "gitlink-cli 代码提交时自动执行 CI 检查(构建、测试、格式化)" +name: wyx_gitlink_cli_ci +description: "master 分支收到合并后自动执行 CI 检查(构建、静态分析、测试、格式化)" trigger: webhook: gitlink@1.0.0 event: @@ -13,11 +13,11 @@ workflow: name: 开始 task: start - ref: git_clone_0 - name: 拉取代码 + name: git clone task: git_clone@1.2.9 input: remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' - ref: '"refs/heads/wyx_branch"' + ref: '"refs/heads/master"' commit_id: '""' depth: 1 needs: @@ -26,12 +26,12 @@ workflow: 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_pass: ((gitlink_cli.wyx_ssh_pass)) 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 检查通过'" + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master 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 && echo '>>> 1. 编译检查' && go build ./... && echo '>>> 2. 静态分析' && go vet ./... && echo '>>> 3. 单元测试 + 覆盖率门禁' && go test -race -coverprofile=coverage.out ./... && go tool cover -func=coverage.out | grep '^total:' && COV=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print int($3)}') && echo '覆盖率: '$COV'% (门禁阈值 78%)' && [ $COV -ge 78 ] && echo '✅ 覆盖率达标' && echo '>>> 4. 格式化修复' && gofmt -s -w . && echo '✅ 所有 CI 检查通过'" needs: - git_clone_0 - ref: end diff --git a/.devops/gitlink-cli.yml b/.devops/gitlink-cli.yml new file mode 100644 index 0000000..d287266 --- /dev/null +++ b/.devops/gitlink-cli.yml @@ -0,0 +1,41 @@ +version: 2 +name: jtx_gitlink_cli +description: "gitlink-cli 项目:代码提交时自动测试、构建并部署到服务器" +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: git clone + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: 测试并部署到服务器 + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((jtx_gitlink_cli.jtx_gitlink_cli_ssh)) + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && docker run --rm -v $(pwd):/build -w /build docker.1ms.run/library/golang:1.26-alpine go test ./... && docker build --no-cache -t gitlink-cli . && (docker stop gitlink-cli || true) && (docker rm gitlink-cli || true) && docker run -d --restart=always --name gitlink-cli gitlink-cli version" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.devops/wyx_gitlink_cli_build.yml b/.devops/wyx_gitlink_cli_build.yml new file mode 100644 index 0000000..6fa094a --- /dev/null +++ b/.devops/wyx_gitlink_cli_build.yml @@ -0,0 +1,41 @@ +version: 2 +name: wyx_gitlink_cli_build +description: "master 分支收到合并后编译构建并验证新命令是否注册成功" +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: git clone + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: 编译并验证命令 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_pass: ((gitlink_cli.wyx_ssh_pass)) + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && echo '>>> 编译二进制' && go build -o gitlink-cli . && echo '>>> 验证根命令' && ./gitlink-cli --help | head -20 && echo '>>> 验证 pm 模块(6条命令)' && ./gitlink-cli pm --help && echo '>>> 验证 wiki 模块(5条命令)' && ./gitlink-cli wiki --help && echo '>>> 验证 export 模块(3条命令)' && ./gitlink-cli export --help && echo '>>> 验证 alias 命令' && ./gitlink-cli alias --help && echo '>>> 验证 browse 命令' && ./gitlink-cli browse --help && echo '>>> 验证 status 命令' && ./gitlink-cli status && echo '>>> 验证批量操作命令' && ./gitlink-cli issue +batch-close --help && ./gitlink-cli issue +series-update --help && ./gitlink-cli member +batch-add --help && echo '✅ 所有命令验证通过(含3条批量操作命令)'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.devops/wyx_gitlink_cli_ci.yml b/.devops/wyx_gitlink_cli_ci.yml new file mode 100644 index 0000000..cea4f8e --- /dev/null +++ b/.devops/wyx_gitlink_cli_ci.yml @@ -0,0 +1,41 @@ +version: 2 +name: wyx_gitlink_cli_ci +description: "master 分支收到合并后自动执行 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: git clone + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: CI 检查 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_pass: ((gitlink_cli.wyx_ssh_pass)) + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master 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 && echo '>>> 1. 编译检查' && go build ./... && echo '>>> 2. 静态分析' && go vet ./... && echo '>>> 3. 单元测试' && go test -race ./... && echo '>>> 4. 格式化修复' && gofmt -s -w . && echo '✅ 所有 CI 检查通过'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.devops/wyx_gitlink_cli_release.yml b/.devops/wyx_gitlink_cli_release.yml new file mode 100644 index 0000000..558d9fa --- /dev/null +++ b/.devops/wyx_gitlink_cli_release.yml @@ -0,0 +1,41 @@ +version: 2 +name: wyx_gitlink_cli_release +description: "部署流水线:打 tag 时交叉编译 Linux/Windows/macOS 三平台二进制并发布 GitLink Release" +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: git clone(含 tag 历史) + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: 交叉编译并发布 Release + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_pass: ((gitlink_cli.wyx_ssh_pass)) + ssh_cmd: >- + "cd /root && rm -rf release && mkdir release && cd release && git clone https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && git fetch --tags && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo dev) && echo '>>> 发布版本: '$VERSION && echo '>>> 1. 编译 Linux amd64' && GOOS=linux GOARCH=amd64 go build -o gitlink-cli-linux-amd64 . && echo '>>> 2. 编译 Windows amd64' && GOOS=windows GOARCH=amd64 go build -o gitlink-cli-windows-amd64.exe . && echo '>>> 3. 编译 macOS arm64' && GOOS=darwin GOARCH=arm64 go build -o gitlink-cli-darwin-arm64 . && echo '>>> 4. 发布 GitLink Release(用服务器已登录的 gitlink-cli)' && gitlink-cli release +create --owner jiangtx --repo gitlink-cli --tag $VERSION --name $VERSION --target master --body '本次发布版本 $VERSION,含 Linux/Windows/macOS 三平台二进制。新增 pm/wiki/export/alias/browse/status 等模块,详见变更说明。' && echo '✅ Release '$VERSION' 发布完成'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a835c17..2bbdb15 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: - name: Lint run: make lint - - name: Test - run: make test + - name: Test with coverage gate + run: make cover - name: Check formatting run: make fmt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d5673c..5957dc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,14 @@ jobs: - name: Scan i18n key references run: go run ./internal/i18n/cmd/check --scan-code - - name: Run Go tests - run: go test ./... + - name: Run Go tests with coverage gate + run: make cover + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ github.sha }} + path: | + coverage.out + coverage.html + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index bd0ccf8..63cdf65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ -gitlink-cli.exe +coverage +coverage.out + +# 验证临时产物 +coverage.verify.out +coverage.html + +# 课程任务材料(非代码) +课程实践任务及要求*.pdf + +# 构建产物二进制(go build / make build 本地产出,不入库;分发走 Release 附件) /gitlink-cli +/gitlink-cli.exe diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8240b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM docker.1ms.run/library/golang:1.26-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o gitlink-cli . + +FROM docker.1ms.run/library/alpine:3.20 + +RUN apk add --no-cache ca-certificates git + +COPY --from=builder /build/gitlink-cli /usr/local/bin/gitlink-cli + +RUN chmod +x /usr/local/bin/gitlink-cli + +ENTRYPOINT ["gitlink-cli"] diff --git a/Makefile b/Makefile index 8c6702d..46b82e2 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,17 @@ fmt: exit 1; \ fi +COVER_THRESHOLD ?= 78 + cover: go test -coverprofile=coverage.out ./... - go tool cover -func=coverage.out + @go tool cover -func=coverage.out | grep '^total:' + @COV=$$(go tool cover -func=coverage.out | grep '^total:' | awk '{print int($$3)}'); \ + echo "总覆盖率: $$COV% (门禁阈值: $(COVER_THRESHOLD)%)"; \ + if [ $$COV -lt $(COVER_THRESHOLD) ]; then \ + echo "❌ 覆盖率 $$COV% 低于门禁阈值 $(COVER_THRESHOLD)%"; exit 1; \ + fi; \ + echo "✓ 覆盖率达标" lint: golangci-lint run ./... diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go index 059b052..a218c79 100644 --- a/cmd/alias/alias.go +++ b/cmd/alias/alias.go @@ -3,11 +3,14 @@ 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. @@ -40,13 +43,32 @@ 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 } - for k, v := range aliases { - fmt.Printf(" %-15s → %s\n", k, v) + 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]) } return nil }, @@ -86,6 +108,23 @@ func NewAliasCmd() *cobra.Command { return nil }, }, + &cobra.Command{ + Use: "+expand ", + Short: "展开别名查看原命令", + Long: "查看一个别名对应的原始命令。如果别名不存在则报错。", + Args: cobra.ExactArgs(1), + Example: ` gitlink-cli alias +expand rl + 输出: rl → repo +list`, + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + expanded, ok := aliases[args[0]] + if !ok { + return fmt.Errorf("别名 %s 不存在", args[0]) + } + fmt.Printf("%s → %s\n", args[0], expanded) + return nil + }, + }, ) return cmd } diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go index 0c54cd6..ebf2ccd 100644 --- a/cmd/alias/alias_test.go +++ b/cmd/alias/alias_test.go @@ -1,16 +1,15 @@ package alias import ( - "bytes" + "io" "os" "strings" "testing" - "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" ) func TestLoadAliasesEmpty(t *testing.T) { - // 设置临时配置目录 tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) @@ -27,7 +26,6 @@ func TestSaveAndLoadAliases(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - // 保存 original := map[string]string{ "rl": "repo +list", "ri": "repo +info", @@ -36,7 +34,6 @@ func TestSaveAndLoadAliases(t *testing.T) { t.Fatalf("saveAliases failed: %v", err) } - // 加载 loaded, err := loadAliases() if err != nil { t.Fatalf("loadAliases failed: %v", err) @@ -56,10 +53,7 @@ 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() @@ -72,7 +66,6 @@ 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() @@ -84,7 +77,7 @@ func TestLoadAliasesInvalidYAML(t *testing.T) { } } -func TestNewAliasCmd(t *testing.T) { +func TestNewAliasCmdStructure(t *testing.T) { cmd := NewAliasCmd() if cmd.Use != "alias" { t.Errorf("expected Use 'alias', got %s", cmd.Use) @@ -92,80 +85,59 @@ func TestNewAliasCmd(t *testing.T) { if !cmd.HasSubCommands() { t.Error("alias command should have subcommands") } + subcmds := cmd.Commands() - if len(subcmds) != 3 { - t.Fatalf("expected 3 subcommands, got %d", len(subcmds)) + if len(subcmds) != 4 { + t.Fatalf("expected 4 subcommands, got %d", len(subcmds)) + } + + expectedUses := map[string]bool{"+list": false, "+set ": false, "+delete ": false, "+expand ": false} + for _, sub := range subcmds { + if _, ok := expectedUses[sub.Use]; ok { + expectedUses[sub.Use] = true + } + } + for use, found := range expectedUses { + if !found { + t.Errorf("subcommand %q not found", use) + } } } -func TestAliasListSubcommand(t *testing.T) { +func TestAliasSetAndDeleteFlow(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - 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") + // 模拟 +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) } - // 无别名时运行 - buf := new(bytes.Buffer) - listCmd.SetOut(buf) - listCmd.SetArgs([]string{}) - if err := listCmd.Execute(); err != nil { - t.Fatalf("list failed: %v", err) + // 验证保存成功 + loaded, _ := loadAliases() + if loaded["rl"] != "repo +list" { + 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 - } + if loaded["ri"] != "repo +info" { + t.Fatalf("alias not saved correctly: %v", loaded) } - // +set - setCmd.SetArgs([]string{"rl", "repo +list"}) - if err := setCmd.Execute(); err != nil { - t.Fatalf("set failed: %v", err) + // 模拟 +delete 操作:删除别名后保存 + delete(loaded, "rl") + if err := saveAliases(loaded); err != nil { + t.Fatalf("saveAliases after delete failed: %v", err) } - // 验证文件写入 - aliases, _ := loadAliases() - if aliases["rl"] != "repo +list" { - t.Fatalf("alias not saved correctly: %v", aliases) + // 验证删除成功 + final, _ := loadAliases() + if _, ok := final["rl"]; ok { + t.Fatal("alias 'rl' should have been deleted") } - - // +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") + if final["ri"] != "repo +info" { + t.Fatal("alias 'ri' should still exist") } } @@ -173,21 +145,80 @@ func TestAliasDeleteNonExistent(t *testing.T) { tmpDir := t.TempDir() t.Setenv("GITLINK_CONFIG_DIR", tmpDir) - cmd := NewAliasCmd() - var deleteCmd *cobra.Command - for _, sub := range cmd.Commands() { - if strings.HasPrefix(sub.Use, "+delete") { - deleteCmd = sub - break - } + // 空别名列表,删除不存在的别名 + aliases, _ := loadAliases() + if _, ok := aliases["nonexistent"]; ok { + t.Fatal("nonexistent alias should not exist") } + // 验证逻辑:别名不存在时不应执行删除 + // 这对应 alias.go 中 if _, ok := aliases[args[0]]; !ok 的检查 +} - 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) +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) + } } } diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 87fc445..41b0f60 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -3,6 +3,7 @@ package auth import ( "errors" "os" + "path/filepath" "testing" "github.com/spf13/cobra" @@ -11,6 +12,16 @@ import ( internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" ) +// setupAuthTest sets up a temporary config directory for auth tests. +// Returns the path to the config dir (.config/gitlink-cli). +func setupAuthTest(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), ".config", "gitlink-cli") + t.Setenv("GITLINK_CONFIG_DIR", dir) + t.Setenv("HOME", filepath.Dir(filepath.Dir(dir))) // for backward compat + return dir +} + func TestEnvTokenVar(t *testing.T) { if envTokenVar != "GITLINK_TOKEN" { t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar) @@ -61,7 +72,7 @@ func TestLoginTokenFlag(t *testing.T) { func TestStatusCmdNotLoggedIn(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") _ = internalAuth.DeleteToken() @@ -76,7 +87,7 @@ func TestStatusCmdNotLoggedIn(t *testing.T) { func TestStatusCmdEnvToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "env-token-123") _ = internalAuth.DeleteToken() @@ -86,12 +97,11 @@ func TestStatusCmdEnvToken(t *testing.T) { func TestStatusCmdStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") - os.MkdirAll(dir+"/.config/gitlink-cli", 0700) - os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("cookie:test=abc"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600) cmd := findSub(NewAuthCmd(), "status") cmd.RunE(cmd, nil) @@ -99,12 +109,11 @@ func TestStatusCmdStoredToken(t *testing.T) { func TestStatusCmdEnvAndStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "env-token") - os.MkdirAll(dir+"/.config/gitlink-cli", 0700) - os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("stored-token"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token"), 0600) cmd := findSub(NewAuthCmd(), "status") if err := cmd.RunE(cmd, nil); err != nil { @@ -114,7 +123,7 @@ func TestStatusCmdEnvAndStoredToken(t *testing.T) { func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") // Don't create credentials file — LoadToken returns empty @@ -126,7 +135,7 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { func TestLogoutCmdError(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") // Don't create credentials dir — DeleteToken will fail @@ -139,14 +148,12 @@ func TestLogoutCmdError(t *testing.T) { func TestLogoutCmd(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + dir := setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") // Store a token first so DeleteToken has something to delete - credDir := home + "/.config/gitlink-cli" - os.MkdirAll(credDir, 0700) - os.WriteFile(credDir+"/credentials", []byte("some-token"), 0600) + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600) cmd := findSub(NewAuthCmd(), "logout") if cmd == nil { @@ -159,8 +166,7 @@ func TestLogoutCmd(t *testing.T) { func TestLoginWithToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + dir := setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") // Mock stdin @@ -185,7 +191,7 @@ func TestLoginWithToken(t *testing.T) { } // Verify token was saved to file - data, err := os.ReadFile(home + "/.config/gitlink-cli/credentials") + data, err := os.ReadFile(filepath.Join(dir, "credentials")) if err != nil { t.Fatalf("read credentials: %v", err) } @@ -196,7 +202,7 @@ func TestLoginWithToken(t *testing.T) { func TestLoginWithTokenEmpty(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") oldStdin := os.Stdin @@ -219,7 +225,7 @@ func TestLoginWithTokenEmpty(t *testing.T) { func TestLoginWithPasswordNoTerminal(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setupAuthTest(t) t.Setenv("GITLINK_TOKEN", "") // term.ReadPassword will fail because test has no terminal diff --git a/cmd/browse/browse.go b/cmd/browse/browse.go index 58400a2..776bf05 100644 --- a/cmd/browse/browse.go +++ b/cmd/browse/browse.go @@ -2,57 +2,164 @@ package browse import ( "fmt" - "os/exec" - "runtime" + "io" + "os" + "strconv" + "strings" "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" ) +// stdout is the browse command's output target (so tests can redirect). +var stdout io.Writer = os.Stdout + +// browsableKinds maps the first path segment of `browse [/id>` to a URL +// builder. The "default" entry is used as a fallback that appends the raw arg +// to the repo URL, preserving the original passthrough behaviour. +var browsableKinds = []struct { + kind string + desc string +}{ + {"issues", "Issue 列表 / 详情 (issues/42)"}, + {"pulls", "PR 列表 / 详情 (pulls/128)"}, + {"wiki", "Wiki 首页 / 页面 (wiki 或 wiki/API指南)"}, + {"actions", "CI/Actions 页面"}, + {"commits", "提交列表 / 详情 (commits/abc123)"}, + {"branches", "分支列表"}, + {"releases", "Release 列表 / 详情 (releases/v2.0)"}, + {"milestones", "里程碑页面"}, + {"labels", "标签管理页"}, + {"settings/hooks", "Webhook 设置页"}, + {"settings/collaboration", "成员管理页"}, + {"projects", "项目看板页"}, +} + // NewBrowseCmd creates the browse command for opening GitLink pages in a browser. func NewBrowseCmd() *cobra.Command { - return &cobra.Command{ + var listFlag, noOpen bool + cmd := &cobra.Command{ Use: "browse [resource]", Short: "在浏览器中打开 GitLink 页面", Long: `打开当前仓库(或指定资源)的 GitLink 页面。 -如果不带参数,打开当前仓库主页。 -资源格式: issues/42, pulls/42, wiki +资源格式: issues/42, pulls/42, wiki, wiki/页面名, commits/abc123, ... +不带参数则打开仓库主页。owner/repo 自动从 git remote 推断或用 --owner/--repo 指定。 -浏览器打开命令: - - macOS: open - - Windows: start - - Linux: xdg-open`, +示例: + 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,不打开浏览器`, Example: ` gitlink-cli browse gitlink-cli browse issues/42 gitlink-cli browse pulls/128 - gitlink-cli browse wiki`, + gitlink-cli browse wiki + gitlink-cli browse --list + gitlink-cli browse --no-open`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := context.ResolveOwnerRepo("", "") + owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) if err != nil { return fmt.Errorf("无法推断仓库信息: %w", err) } - url := fmt.Sprintf("https://gitlink.org.cn/%s/%s", owner, repo) - if len(args) > 0 { - url += "/" + args[0] + + if listFlag { + listBrowsables(owner, repo) + return nil } - fmt.Printf("正在打开: %s\n", url) - return openBrowser(url) + + 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 }, } + cmd.Flags().BoolVar(&listFlag, "list", false, "列出当前仓库所有可浏览的页面") + cmd.Flags().BoolVar(&noOpen, "no-open", false, "只打印 URL,不打开浏览器") + return cmd +} + +// emitBrowse prints the URL — friendly single line by default, structured +// envelope when --format is set. +func emitBrowse(r *web.ResourceURL) { + if cmdutil.Format == "" { + fmt.Fprintf(stdout, "🔗 %s\n", r.URL) + return + } + _ = output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format) +} + +// listBrowsables prints the catalog of pages `browse` understands. +func listBrowsables(owner, repo string) { + fmt.Fprintf(stdout, "可浏览的 GitLink 页面 (%s/%s):\n", owner, repo) + for _, k := range browsableKinds { + fmt.Fprintf(stdout, " %-28s %s\n", k.kind, k.desc) + } + fmt.Fprintf(stdout, "\n用法: gitlink-cli browse <资源>\n") +} + +// resolveBrowseURL maps `browse ` to a web URL. With no arg → repo home. +func resolveBrowseURL(b *web.Builder, owner, repo string, args []string) *web.ResourceURL { + if len(args) == 0 || args[0] == "" { + return b.RepoURL(owner, repo) + } + arg := strings.TrimPrefix(args[0], "/") + // Split into kind and (optional) rest after the first "/". + kind, rest, _ := strings.Cut(arg, "/") + rest = strings.Trim(rest, "/") + + switch { + case kind == "issues" || kind == "issue": + return b.IssueURL(owner, repo, atoiOrZero(rest)) + case kind == "pulls" || kind == "pr" || kind == "pull": + return b.PRURL(owner, repo, atoiOrZero(rest)) + case kind == "wiki": + return b.WikiURL(owner, repo, rest) + case kind == "actions" || kind == "ci": + return b.CIURL(owner, repo) + case kind == "commits": + return b.CommitURL(owner, repo, rest) + case kind == "branches": + return b.BranchURL(owner, repo, rest) + case kind == "releases": + return b.ReleaseURL(owner, repo, rest) + case kind == "milestones": + return b.MilestoneURL(owner, repo) + case kind == "labels": + return b.LabelURL(owner, repo) + case arg == "settings/hooks": + return b.WebhookURL(owner, repo) + case arg == "settings/collaboration": + return b.MemberURL(owner, repo) + case kind == "settings": + return b.RepoURL(owner, repo) // settings landing falls back to repo home + 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, + } + } } -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: - cmd = exec.Command("xdg-open", url) +func atoiOrZero(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 } - return cmd.Start() + return n } diff --git a/cmd/browse/browse_test.go b/cmd/browse/browse_test.go index 812e911..e2b1715 100644 --- a/cmd/browse/browse_test.go +++ b/cmd/browse/browse_test.go @@ -1,8 +1,12 @@ 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) { @@ -20,7 +24,6 @@ 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) } @@ -32,9 +35,8 @@ func TestBrowseCmdHasCorrectArgs(t *testing.T) { } } -func TestBrowseCmdSubcommandStructure(t *testing.T) { +func TestBrowseCmdNoSubcommands(t *testing.T) { cmd := NewBrowseCmd() - // browse 不应该有子命令 if cmd.HasSubCommands() { t.Error("browse should not have subcommands") } @@ -50,9 +52,125 @@ func TestBrowseCmdExample(t *testing.T) { } } -func TestOpenBrowserReturnsNoError(t *testing.T) { - // openBrowser 在所有平台都应该返回 nil 或一个 error - // 在无头环境下可能会失败,但不应该 panic - _ = openBrowser("https://gitlink.org.cn") - // 只要不 panic 就行 +func TestBrowseCmdHasListAndNoOpenFlags(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Flags().Lookup("list") == nil { + t.Error("missing --list flag") + } + if cmd.Flags().Lookup("no-open") == nil { + t.Error("missing --no-open flag") + } +} + +func TestResolveBrowseURL(t *testing.T) { + b := web.NewBuilder() + cases := []struct { + name string + args []string + wantSub string + }{ + {"no args → repo", nil, "/o/r"}, + {"issue detail", []string{"issues/42"}, "/issues/42"}, + {"issue alias", []string{"issue/7"}, "/issues/7"}, + {"pr detail", []string{"pulls/128"}, "/pulls/128"}, + {"pr alias", []string{"pr/9"}, "/pulls/9"}, + {"wiki index", []string{"wiki"}, "/wiki"}, + {"wiki page", []string{"wiki/Guide"}, "/wiki/Guide"}, + {"ci", []string{"actions"}, "/actions"}, + {"ci alias", []string{"ci"}, "/actions"}, + {"commit", []string{"commits/abc123"}, "/commits/abc123"}, + {"release", []string{"releases/v2.0"}, "/releases/v2.0"}, + {"milestones", []string{"milestones"}, "/milestones"}, + {"labels", []string{"labels"}, "/issues/labels"}, + {"webhook settings", []string{"settings/hooks"}, "/settings/hooks"}, + {"collaboration", []string{"settings/collaboration"}, "/settings/collaboration"}, + {"unknown passthrough", []string{"custom/seg"}, "/custom/seg"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := resolveBrowseURL(b, "o", "r", c.args) + if !strings.Contains(r.URL, c.wantSub) { + t.Errorf("URL %q missing %q", r.URL, c.wantSub) + } + }) + } +} + +func TestBrowseListOutputsCatalog(t *testing.T) { + out := runBrowse(t, "--owner", "o", "--repo", "r", "--list") + for _, want := range []string{"issues", "pulls", "wiki", "actions"} { + if !strings.Contains(out, want) { + t.Errorf("list missing %q: %q", want, out) + } + } +} + +func TestBrowseJSONFormat(t *testing.T) { + // --format json must route emitBrowse through the output envelope. + out := runBrowseFmt(t, "json", "issues/42") + if !strings.Contains(out, `"html_url"`) { + t.Errorf("JSON browse missing html_url: %q", out) + } +} + +func TestResolveBrowseURLIssueNonNumeric(t *testing.T) { + // atoiOrZero("abc") should fall back to 0 (issue list page). + r := resolveBrowseURL(web.NewBuilder(), "o", "r", []string{"issues/abc"}) + if !strings.HasSuffix(r.URL, "/issues") { + t.Errorf("expected /issues fallback, got %q", r.URL) + } +} + +func TestBrowseNoOpenDoesNotLaunchBrowser(t *testing.T) { + // --no-open must print the URL but never invoke a browser. We can't easily + // stub web.OpenBrowser across packages, so we assert the URL is printed + // and that the "browser did not open" warning (printed only when + // OpenBrowser returns an error) is absent. + out := runBrowseNoOpen(t, "issues/42", true) + if !strings.Contains(out, "/issues/42") { + t.Errorf("expected /issues/42 in output: %q", out) + } + if strings.Contains(out, "浏览器未自动打开") { + t.Errorf("--no-open should not print open-failure warning: %q", out) + } +} + +// runBrowse runs `browse ` with captured stdout. +func runBrowse(t *testing.T, args ...string) string { + t.Helper() + old := stdout + oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format + buf := &bytes.Buffer{} + stdout = buf + defer func() { + stdout = old + cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt + }() + cmd := NewBrowseCmd() + cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "") + cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "") + cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "") + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("browse %v: %v", args, err) + } + return buf.String() +} + +// runBrowseFmt runs `browse ` with a specific --format value. +func runBrowseFmt(t *testing.T, format, resource string) string { + t.Helper() + return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource) +} + +func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string { + t.Helper() + args := []string{"--owner", "o", "--repo", "r"} + if resource != "" { + args = append(args, resource) + } + if noOpen { + args = append(args, "--no-open") + } + return runBrowse(t, args...) } diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index bea4c28..e72b9dc 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -7,4 +7,10 @@ var ( Format string Debug bool Lang string + // Web, when true, asks shortcut commands to print and open the GitLink web + // URL corresponding to the resource they just operated on. + Web bool + // Demo, when true (or GITLINK_DEMO=1), routes every API call through the + // in-process mock transport so the CLI runs with no network and no token. + Demo bool ) diff --git a/cmd/demo/demo.go b/cmd/demo/demo.go new file mode 100644 index 0000000..30efe5b --- /dev/null +++ b/cmd/demo/demo.go @@ -0,0 +1,121 @@ +// Package demo implements `gitlink-cli demo +run `: a pre-baked, +// projector-friendly runner that walks through the subtask-1 demo script with +// GITLINK_DEMO=1 forced on, so the whole showcase runs offline. +package demo + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +// stdout is the runner's output target (so tests can redirect). +var stdout io.Writer = os.Stdout + +// NewDemoCmd builds the `demo` command tree. +func NewDemoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "demo", + Short: "演示模式 — 无需 Token 即可展示全部功能", + Long: `运行预编排的演示脚本,自动开启 GITLINK_DEMO=1,所有命令走内置 Mock 数据, +无需网络和 Token,适合课堂投影演示。 + +可用 showcase: + showcase 全功能展示(覆盖五大类别,~20 min) + quick-tour 5 分钟快速导览`, + } + cmd.AddCommand(newRunCmd()) + return cmd +} + +func newRunCmd() *cobra.Command { + var listOnly bool + cmd := &cobra.Command{ + Use: "+run [showcase]", + Short: "运行(或列出)预编排演示脚本", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if listOnly { + for _, s := range showcases { + fmt.Fprintf(stdout, "%-12s %s\n", s.Name, s.Description) + } + return nil + } + name := "showcase" + if len(args) > 0 { + name = args[0] + } + sc, ok := findShowcase(name) + if !ok { + return fmt.Errorf("未找到 showcase %q,运行 `demo +run --list` 查看可用项", name) + } + return runShowcase(sc) + }, + } + cmd.Flags().BoolVar(&listOnly, "list", false, "列出所有可用 showcase") + return cmd +} + +// runStep is the per-step executor. It is a package-level variable so tests +// can swap it for a no-op instead of recursing into the test binary (which +// os.Args[0] points at during go test). +var runStep = func(bin string, argv []string, env []string, out io.Writer) error { + c := exec.Command(bin, argv...) + c.Env = env + c.Stdout = out + c.Stderr = out + return c.Run() +} + +// runShowcase prints + executes each step. Each step invokes the gitlink-cli +// binary (os.Args[0]) with GITLINK_DEMO=1 so commands return mock data even on +// a machine without network/token. Step failures never abort the showcase. +func runShowcase(sc Showcase) error { + fmt.Fprintf(stdout, "╔════ %s ════╗\n", sc.Name) + fmt.Fprintf(stdout, " %s\n 步数: %d\n\n", sc.Description, len(sc.Steps)) + bin := os.Args[0] + env := append(os.Environ(), "GITLINK_DEMO=1") + for i, step := range sc.Steps { + fmt.Fprintf(stdout, "\n━━━ [%d/%d] %s ━━━\n", i+1, len(sc.Steps), step.Desc) + fmt.Fprintf(stdout, "▶ gitlink-cli %s\n", step.Cmd) + argv := splitArgs(step.Cmd) + if len(argv) == 0 { + continue + } + if err := runStep(bin, argv, env, stdout); err != nil { + // Demo never aborts on a step failure — just narrate it. + fmt.Fprintf(stdout, " (本步返回错误: %v)\n", err) + } + } + fmt.Fprintf(stdout, "\n╚════ 演示结束(共 %d 步) ════╝\n", len(sc.Steps)) + return nil +} + +// splitArgs tokenises a command line. It honours double-quoted segments so +// flags like --title "a b c" survive intact. +func splitArgs(line string) []string { + var out []string + var cur strings.Builder + inQ := false + for _, r := range line { + switch { + case r == '"': + inQ = !inQ + case (r == ' ' || r == '\t') && !inQ: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} diff --git a/cmd/demo/demo_test.go b/cmd/demo/demo_test.go new file mode 100644 index 0000000..c6a0d6b --- /dev/null +++ b/cmd/demo/demo_test.go @@ -0,0 +1,156 @@ +package demo + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" +) + +func TestNewDemoCmd(t *testing.T) { + cmd := NewDemoCmd() + if cmd.Use != "demo" { + t.Errorf("Use = %q, want demo", cmd.Use) + } + if !cmd.HasSubCommands() { + t.Error("demo should have +run subcommand") + } +} + +func TestRunList(t *testing.T) { + out := runRun(t, "--list") + if !strings.Contains(out, "showcase") { + t.Errorf("list missing showcase: %q", out) + } + if !strings.Contains(out, "quick-tour") { + t.Errorf("list missing quick-tour: %q", out) + } +} + +func TestRunUnknownShowcaseErrors(t *testing.T) { + root := NewDemoCmd() + root.SetArgs([]string{"+run", "does-not-exist"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for unknown showcase") + } + if !strings.Contains(err.Error(), "does-not-exist") { + t.Errorf("error should name the missing showcase: %v", err) + } +} + +func TestFindShowcase(t *testing.T) { + if _, ok := findShowcase("showcase"); !ok { + t.Error("showcase should exist") + } + if _, ok := findShowcase("quick-tour"); !ok { + t.Error("quick-tour should exist") + } + if _, ok := findShowcase("missing"); ok { + t.Error("missing should not exist") + } +} + +func TestSplitArgs(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"version", []string{"version"}}, + {"issue +list --owner o --repo r", []string{"issue", "+list", "--owner", "o", "--repo", "r"}}, + {`--title "a b c" --x`, []string{"--title", "a b c", "--x"}}, + {" multiple spaces ", []string{"multiple", "spaces"}}, + } + for _, c := range cases { + got := splitArgs(c.in) + if len(got) != len(c.want) { + t.Errorf("splitArgs(%q) = %v, want %v", c.in, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitArgs(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + +func TestRunShowcasePrintsHeaderAndFooter(t *testing.T) { + // Stub the per-step executor so we don't recurse into the test binary. + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { return nil } + defer func() { runStep = oldStep }() + + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + + sc := Showcase{Name: "t", Description: "test", Steps: []Step{{Desc: "s1", Cmd: "version"}}} + if err := runShowcase(sc); err != nil { + t.Fatalf("runShowcase: %v", err) + } + out := buf.String() + if !strings.Contains(out, "test") { + t.Errorf("missing description: %q", out) + } + if !strings.Contains(out, "演示结束") { + t.Errorf("missing footer: %q", out) + } +} + +func TestRunShowcaseNarratesStepFailure(t *testing.T) { + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { return fmt.Errorf("boom") } + defer func() { runStep = oldStep }() + + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + + sc := Showcase{Name: "t", Description: "x", Steps: []Step{{Desc: "s", Cmd: "version"}}} + _ = runShowcase(sc) + if !strings.Contains(buf.String(), "本步返回错误") { + t.Errorf("expected step-failure narration: %q", buf.String()) + } +} + +func TestRunShowcaseSkipsEmptyCmd(t *testing.T) { + called := false + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { called = true; return nil } + defer func() { runStep = oldStep }() + old := stdout + stdout = &bytes.Buffer{} + defer func() { stdout = old }() + + _ = runShowcase(Showcase{Name: "n", Description: "d", Steps: []Step{{Desc: "empty", Cmd: ""}}}) + if called { + t.Error("runStep should not be called for empty Cmd") + } +} + +func TestRunListFlagWired(t *testing.T) { + cmd := newRunCmd() + if cmd.Flags().Lookup("list") == nil { + t.Error("missing --list flag on +run") + } +} + +// runRun executes `demo +run ` with captured stdout. +func runRun(t *testing.T, args ...string) string { + t.Helper() + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + root := NewDemoCmd() + root.SetArgs(append([]string{"+run"}, args...)) + if err := root.Execute(); err != nil { + t.Fatalf("demo +run %v: %v", args, err) + } + return buf.String() +} diff --git a/cmd/demo/showcases.go b/cmd/demo/showcases.go new file mode 100644 index 0000000..90c2f98 --- /dev/null +++ b/cmd/demo/showcases.go @@ -0,0 +1,69 @@ +package demo + +// Showcase is a pre-baked, projector-friendly demo script: a titled sequence of +// gitlink-cli invocations. Each step runs with GITLINK_DEMO=1 so the whole +// showcase is offline and token-free. +type Showcase struct { + Name string + Description string + Steps []Step +} + +// Step is one CLI invocation inside a showcase. +type Step struct { + Desc string // human-readable narration for the slide + Cmd string // the gitlink-cli args (without the binary name) +} + +// showcases is the catalog. Add new entries here; +run --list reads from it. +var showcases = []Showcase{ + { + Name: "showcase", + Description: "GitLink-CLI 全功能展示(覆盖子任务一五大类别,~20 min)", + Steps: []Step{ + {"版本与命令全景", "version"}, + {"自动上下文 + 健康检查", "status"}, + {"能力探测", "capability +check"}, + {"终端↔网页联动(仓库主页)", "show repo --owner jiangtx --repo gitlink-cli-demo"}, + {"Issue 详情页 URL", "show issue --owner jiangtx --repo gitlink-cli-demo --number 42"}, + + {"第一类·Webhook 列表", "webhook +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·Wiki 页面", "wiki +pages --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·标签列表", "label +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·成员列表", "member +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·里程碑", "milestone +list --owner jiangtx --repo gitlink-cli-demo"}, + + {"第二类·三种输出格式(JSON)", "repo +list --format json"}, + {"第二类·Table 格式", "repo +list --format table"}, + {"第二类·调试模式", "issue +list --owner jiangtx --repo gitlink-cli-demo --debug"}, + + {"第三类·批量关闭 dry-run", "issue +batch-close --owner jiangtx --repo gitlink-cli-demo --numbers 1,2,3 --dry-run"}, + {"第三类·批量加成员 dry-run", "member +batch-add --owner jiangtx --repo gitlink-cli-demo --user-ids 101,102 --dry-run"}, + {"第三类·导出 Issue", "export +issues --owner jiangtx --repo gitlink-cli-demo --format csv --output demo_issues.csv"}, + + {"第五类·语言占比", "repo +languages --owner jiangtx --repo gitlink-cli-demo"}, + {"第五类·贡献者", "repo +contributors --owner jiangtx --repo gitlink-cli-demo"}, + {"第五类·用户热力图", "user +heatmap --login jiangtx"}, + }, + }, + { + Name: "quick-tour", + Description: "5 分钟快速导览(仅核心命令)", + Steps: []Step{ + {"版本", "version"}, + {"仓库主页 URL", "show repo --owner jiangtx --repo gitlink-cli-demo"}, + {"Issue 列表", "issue +list --owner jiangtx --repo gitlink-cli-demo"}, + {"Webhook 列表", "webhook +list --owner jiangtx --repo gitlink-cli-demo"}, + {"能力探测", "capability +check"}, + }, + }, +} + +func findShowcase(name string) (Showcase, bool) { + for _, s := range showcases { + if s.Name == name { + return s, true + } + } + return Showcase{}, false +} diff --git a/cmd/root.go b/cmd/root.go index 1e83fb5..4458abb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,12 +9,15 @@ import ( 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" + browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + demoCmd "github.com/gitlink-org/gitlink-cli/cmd/demo" + showCmd "github.com/gitlink-org/gitlink-cli/cmd/show" + statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/demo" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -54,6 +57,8 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { 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().BoolVar(&cmdutil.Web, "web", false, "执行后打印并打开对应 GitLink 网页 URL") + rootCmd.PersistentFlags().BoolVar(&demo.DemoFlag, "demo", false, "演示模式:使用内置 Mock 数据,无需网络和 Token") rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) @@ -63,6 +68,8 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(aliasCmd.NewAliasCmd()) rootCmd.AddCommand(browseCmd.NewBrowseCmd()) rootCmd.AddCommand(statusCmd.NewStatusCmd()) + rootCmd.AddCommand(showCmd.NewShowCmd()) + rootCmd.AddCommand(demoCmd.NewDemoCmd()) shortcuts.RegisterAll(rootCmd, tr) diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..d2ff862 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,292 @@ +// Package show implements `gitlink-cli show `: print the GitLink web +// URL for a resource without opening a browser. It is the projector-friendly +// twin of `browse` — exactly the same URL table (via internal/web.Builder), +// but it prints instead of launching. +// +// Output convention: +// - default (no --format): a single human-readable line "🔗 " +// - --format json/table/yaml: a standard output envelope +// +// This dual mode lets the demo script `gitlink-cli show repo` render a tidy +// URL on slides while `show repo --format json` stays machine-parsable for AI +// Agents and verify.sh. +package show + +import ( + "fmt" + "io" + "os" + + "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 emit target. Package-level so tests can redirect it. +var stdout io.Writer = os.Stdout + +// NewShowCmd builds the `show` command tree. +func NewShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "打印 GitLink 网页 URL(不打开浏览器)", + Long: `打印资源对应的 GitLink 网页 URL,用于投影演示。 + +默认输出一行 🔗 URL;加 --format json 输出结构化 envelope(供 AI Agent / 脚本)。 +owner/repo 自动从 git remote 推断,或用 --owner/--repo 指定。 + +示例: + gitlink-cli show repo + gitlink-cli show issue --number 42 + gitlink-cli show pr --number 128 + gitlink-cli show wiki --page "API 指南" + gitlink-cli show webhook + gitlink-cli show user --login jiangtx`, + } + cmd.AddCommand( + newRepoCmd(), + newIssueCmd(), + newPRCmd(), + newWikiCmd(), + newMemberCmd(), + newWebhookCmd(), + newLabelCmd(), + newMilestoneCmd(), + newBranchCmd(), + newReleaseCmd(), + newCommitCmd(), + newCICmd(), + newCompareCmd(), + newOrgCmd(), + newUserCmd(), + newNotificationCmd(), + ) + return cmd +} + +// emit prints r in the configured format. Empty format → friendly single line. +func emit(r *web.ResourceURL) error { + if cmdutil.Format == "" { + fmt.Fprintf(stdout, "🔗 %s\n", r.URL) + return nil + } + return output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format) +} + +// resolveOwnerRepo reads global --owner/--repo and falls back to git remote. +func resolveOwnerRepo() (string, string, error) { + return context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) +} + +// ownerRepoCmd builds a subcommand that only needs owner/repo. +func ownerRepoCmd(use, short string, build func(b *web.Builder, owner, repo string) *web.ResourceURL) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(build(web.NewBuilder(), owner, repo)) + }, + } +} + +func newRepoCmd() *cobra.Command { + return ownerRepoCmd("repo", "仓库主页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.RepoURL(o, r) }) +} + +func newIssueCmd() *cobra.Command { + var number int + cmd := &cobra.Command{ + Use: "issue", + Short: "Issue 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().IssueURL(owner, repo, number)) + }, + } + cmd.Flags().IntVarP(&number, "number", "n", 0, "Issue 编号(省略则输出 Issue 列表页)") + return cmd +} + +func newPRCmd() *cobra.Command { + var number int + cmd := &cobra.Command{ + Use: "pr", + Short: "Pull Request 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().PRURL(owner, repo, number)) + }, + } + cmd.Flags().IntVarP(&number, "number", "n", 0, "PR 编号(省略则输出 PR 列表页)") + return cmd +} + +func newWikiCmd() *cobra.Command { + var page string + cmd := &cobra.Command{ + Use: "wiki", + Short: "Wiki 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().WikiURL(owner, repo, page)) + }, + } + cmd.Flags().StringVarP(&page, "page", "p", "", "Wiki 页面名(省略则输出 Wiki 首页)") + return cmd +} + +func newMemberCmd() *cobra.Command { + return ownerRepoCmd("member", "成员管理页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.MemberURL(o, r) }) +} + +func newWebhookCmd() *cobra.Command { + return ownerRepoCmd("webhook", "Webhook 设置页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.WebhookURL(o, r) }) +} + +func newLabelCmd() *cobra.Command { + return ownerRepoCmd("label", "标签管理页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.LabelURL(o, r) }) +} + +func newMilestoneCmd() *cobra.Command { + return ownerRepoCmd("milestone", "里程碑页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.MilestoneURL(o, r) }) +} + +func newBranchCmd() *cobra.Command { + var branch string + cmd := &cobra.Command{ + Use: "branch", + Short: "分支页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().BranchURL(owner, repo, branch)) + }, + } + cmd.Flags().StringVarP(&branch, "branch", "b", "", "分支名(省略则输出分支列表页)") + return cmd +} + +func newReleaseCmd() *cobra.Command { + var tag string + cmd := &cobra.Command{ + Use: "release", + Short: "Release 页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().ReleaseURL(owner, repo, tag)) + }, + } + cmd.Flags().StringVarP(&tag, "tag", "t", "", "Release tag(省略则输出 Release 列表页)") + return cmd +} + +func newCommitCmd() *cobra.Command { + var sha string + cmd := &cobra.Command{ + Use: "commit", + Short: "提交详情页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().CommitURL(owner, repo, sha)) + }, + } + cmd.Flags().StringVarP(&sha, "sha", "s", "", "提交 SHA(省略则输出提交列表页)") + return cmd +} + +func newCICmd() *cobra.Command { + return ownerRepoCmd("ci", "CI/Actions 页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.CIURL(o, r) }) +} + +func newCompareCmd() *cobra.Command { + var base, head string + cmd := &cobra.Command{ + Use: "compare", + Short: "分支对比页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if base == "" || head == "" { + return fmt.Errorf("--base 和 --head 均为必填") + } + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().CompareURL(owner, repo, base, head)) + }, + } + cmd.Flags().StringVar(&base, "base", "", "基准分支/tag") + cmd.Flags().StringVar(&head, "head", "", "目标分支/tag") + return cmd +} + +func newOrgCmd() *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "org", + Short: "组织页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name 为必填") + } + return emit(web.NewBuilder().OrgURL(name)) + }, + } + cmd.Flags().StringVar(&name, "name", "", "组织名") + return cmd +} + +func newUserCmd() *cobra.Command { + var login string + cmd := &cobra.Command{ + Use: "user", + Short: "用户主页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if login == "" { + return fmt.Errorf("--login 为必填") + } + return emit(web.NewBuilder().UserURL(login)) + }, + } + cmd.Flags().StringVar(&login, "login", "", "用户登录名") + return cmd +} + +func newNotificationCmd() *cobra.Command { + return &cobra.Command{ + Use: "notification", + Short: "通知中心 URL", + RunE: func(cmd *cobra.Command, args []string) error { + return emit(web.NewBuilder().NotificationURL()) + }, + } +} diff --git a/cmd/show/show_test.go b/cmd/show/show_test.go new file mode 100644 index 0000000..8715c70 --- /dev/null +++ b/cmd/show/show_test.go @@ -0,0 +1,214 @@ +package show + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/spf13/cobra" +) + +// newShowWithFlags builds a show tree with owner/repo/format persistent flags +// that normally come from the parent rootCmd, so subcommands can parse them in +// isolation during tests. +func newShowWithFlags() *cobra.Command { + cmdutil.Owner = "" + cmdutil.Repo = "" + cmdutil.Format = "" + r := NewShowCmd() + r.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "") + r.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "") + r.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "") + return r +} + +// runShow executes `show ` with captured stdout and returns the output. +func runShow(t *testing.T, args ...string) string { + t.Helper() + oldOut := stdout + oldFmt := cmdutil.Format + buf := &bytes.Buffer{} + stdout = buf + cmdutil.Format = "" + defer func() { + stdout = oldOut + cmdutil.Format = oldFmt + }() + root := newShowWithFlags() + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("show %v: %v", args, err) + } + return buf.String() +} + +func TestShowSubcommandsRegistered(t *testing.T) { + root := NewShowCmd() + want := []string{ + "repo", "issue", "pr", "wiki", "member", "webhook", "label", + "milestone", "branch", "release", "commit", "ci", "compare", + "org", "user", "notification", + } + got := map[string]bool{} + for _, c := range root.Commands() { + got[c.Name()] = true + } + for _, w := range want { + if !got[w] { + t.Errorf("subcommand %q not registered", w) + } + } +} + +func TestShowRepoDefault(t *testing.T) { + out := runShow(t, "repo", "--owner", "jiangtx", "--repo", "gitlink-cli-demo") + if !strings.Contains(out, "🔗") { + t.Errorf("missing 🔗 marker: %q", out) + } + if !strings.Contains(out, "https://gitlink.org.cn/jiangtx/gitlink-cli-demo") { + t.Errorf("wrong URL: %q", out) + } +} + +func TestShowIssueWithNumber(t *testing.T) { + out := runShow(t, "issue", "--owner", "jiangtx", "--repo", "demo", "--number", "42") + if !strings.Contains(out, "/issues/42") { + t.Errorf("expected /issues/42 in: %q", out) + } +} + +func TestShowIssueListWhenNoNumber(t *testing.T) { + out := runShow(t, "issue", "--owner", "jiangtx", "--repo", "demo") + if !strings.HasSuffix(strings.TrimSpace(out), "/issues") { + t.Errorf("expected /issues suffix: %q", out) + } +} + +func TestShowPR(t *testing.T) { + out := runShow(t, "pr", "--owner", "o", "--repo", "r", "--number", "128") + if !strings.Contains(out, "/pulls/128") { + t.Errorf("expected /pulls/128: %q", out) + } +} + +func TestShowWiki(t *testing.T) { + out := runShow(t, "wiki", "--owner", "o", "--repo", "r", "--page", "API 指南") + if !strings.Contains(out, "/wiki/") { + t.Errorf("expected /wiki/ segment: %q", out) + } +} + +func TestShowWebhook(t *testing.T) { + out := runShow(t, "webhook", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/settings/hooks") { + t.Errorf("expected /settings/hooks: %q", out) + } +} + +func TestShowMember(t *testing.T) { + out := runShow(t, "member", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/settings/collaboration") { + t.Errorf("expected collaboration URL: %q", out) + } +} + +func TestShowLabel(t *testing.T) { + out := runShow(t, "label", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/issues/labels") { + t.Errorf("expected labels URL: %q", out) + } +} + +func TestShowMilestone(t *testing.T) { + out := runShow(t, "milestone", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/milestones") { + t.Errorf("expected milestones URL: %q", out) + } +} + +func TestShowBranch(t *testing.T) { + out := runShow(t, "branch", "--owner", "o", "--repo", "r", "--branch", "feat/x") + if !strings.Contains(out, "/branches/") { + t.Errorf("expected /branches/ segment: %q", out) + } +} + +func TestShowRelease(t *testing.T) { + out := runShow(t, "release", "--owner", "o", "--repo", "r", "--tag", "v2.0") + if !strings.Contains(out, "/releases/v2.0") { + t.Errorf("expected /releases/v2.0: %q", out) + } +} + +func TestShowCommit(t *testing.T) { + out := runShow(t, "commit", "--owner", "o", "--repo", "r", "--sha", "abc123") + if !strings.Contains(out, "/commits/abc123") { + t.Errorf("expected /commits/abc123: %q", out) + } +} + +func TestShowCI(t *testing.T) { + out := runShow(t, "ci", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/actions") { + t.Errorf("expected /actions: %q", out) + } +} + +func TestShowCompare(t *testing.T) { + out := runShow(t, "compare", "--owner", "o", "--repo", "r", "--base", "master", "--head", "dev") + if !strings.Contains(out, "/compare/master...dev") { + t.Errorf("expected compare URL: %q", out) + } +} + +func TestShowOrg(t *testing.T) { + out := runShow(t, "org", "--name", "ccf") + if !strings.HasSuffix(strings.TrimSpace(out), "/ccf") { + t.Errorf("expected /ccf suffix: %q", out) + } +} + +func TestShowUser(t *testing.T) { + out := runShow(t, "user", "--login", "jiangtx") + if !strings.HasSuffix(strings.TrimSpace(out), "/jiangtx") { + t.Errorf("expected /jiangtx suffix: %q", out) + } +} + +func TestShowNotification(t *testing.T) { + out := runShow(t, "notification") + if !strings.Contains(out, "/notifications") { + t.Errorf("expected /notifications: %q", out) + } +} + +func TestShowJSONFormat(t *testing.T) { + // JSON mode must produce a parseable envelope containing html_url. + oldOut := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = oldOut }() + + root := newShowWithFlags() + cmdutil.Format = "json" + root.SetArgs([]string{"repo", "--owner", "o", "--repo", "r"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(buf.String(), `"html_url"`) { + t.Errorf("JSON missing html_url: %q", buf.String()) + } + if !strings.Contains(buf.String(), `"ok": true`) { + t.Errorf("JSON missing ok:true: %q", buf.String()) + } +} + +func TestShowCompareRequiresBothFlags(t *testing.T) { + root := newShowWithFlags() + root.SetArgs([]string{"compare", "--owner", "o", "--repo", "r", "--base", "master"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error when --head missing") + } +} diff --git a/contributors.csv b/contributors.csv new file mode 100644 index 0000000..cae6f31 --- /dev/null +++ b/contributors.csv @@ -0,0 +1,2 @@ +id,login,contributions +,, diff --git a/doc/DEMO-SCRIPT-SUBTASK1.md b/doc/DEMO-SCRIPT-SUBTASK1.md new file mode 100644 index 0000000..e338ba2 --- /dev/null +++ b/doc/DEMO-SCRIPT-SUBTASK1.md @@ -0,0 +1,387 @@ +# 子任务一:终端↔网页联动演示方案 + +> 对齐 `课程实践任务及要求 - 0613.pdf` 子任务一的五大类别 +> +> 核心思路:**终端执行命令 → `show` 输出网页 URL → 投影仪打开网页 → 听众直观验证** + +--- + +## 演示路线图(~20 min) + +``` +开场 (2 min) 安装 + 认证 + 全景概览 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第一类 (4 min) 新增 Shortcut 命令 + Wiki/Webhook/PM/Pipeline/Label/Member/Milestone +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第二类 (3 min) 优化现有命令 + 多格式输出/自动上下文/--debug/中文帮助 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第三类 (3 min) 批量操作能力 + batch-close/batch-add/export/dry-run +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第四类 (3 min) 跨平台兼容 + 安装体验 + 5种安装/npm postinstall/keyring多平台 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第五类 (3 min) 补全 Raw API 封装 + HTML检测/端点→Shortcut映射/Skills修复 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +收尾 (2 min) Capability检测 + 覆盖率 + 验收清单 +``` + +--- + +## 第一类:新增 Shortcut 命令(4 min) + +> **覆盖**: Wiki 管理 · Webhook 配置 · 项目看板 · 流水线 · 标签 · 成员 · 里程碑 +> +> **话术**: "扩展前这些模块完全不存在。扩展后每个都是完整的 CRUD + 列表查询。" + +### A. Webhook 管理 — 从无到有的完整 CRUD + +```bash +# ── 终端 Step 1: 创建 Webhook ── +gitlink-cli webhook +create --owner jiangtx --repo gitlink-cli-demo \ + --url https://my-ci.gitlink.org.cn/hook --events push,create +# → ✓ Webhook 创建成功 (id: 51348) + +# ── 终端 Step 2: 列出所有 Webhook ── +gitlink-cli webhook +list --owner jiangtx --repo gitlink-cli-demo +# → { "ok": true, "data": { "total_count": 1, "webhooks": [...] } } + +# ── 终端 Step 3: 获取网页链接 ── +gitlink-cli show webhook +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 【投影仪】打开 Webhook 设置页面,展示刚才创建的 Webhook +``` + +``` +终端输出 网页对应 +──────── ──────── +webhook +create 成功 → settings/hooks 页面显示新增条目 +webhook +list (JSON) → settings/hooks 列表视图 +webhook +test --id 51348 → settings/hooks/51348 测试结果 +``` + +### B. Wiki 管理 — 文档的 CLI 操作 + +```bash +gitlink-cli wiki +pages --owner jiangtx --repo gitlink-cli-demo +# → 列出所有 Wiki 页面 + +gitlink-cli wiki +create --owner jiangtx --repo gitlink-cli-demo \ + --title "API 使用指南" --content "# 快速开始\n\n## 安装\n..." +# → ✓ Wiki 页面创建成功 + +gitlink-cli show wiki --page "API 使用指南" +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20使用指南 +# 【投影仪】打开 Wiki 页面,展示刚创建的内容 +``` + +### C. 项目管理(PM) + Pipeline + Label + Member + Milestone + +```bash +# PM — 看板 +gitlink-cli pm +dashboards --project 1 → show repo --tab projects +# Pipeline — 流水线 +gitlink-cli pipeline +runs --owner jiangtx --repo gitlink-cli-demo --ref master --workflow build.yml +gitlink-cli show ci → .../actions (流水线页面) +# Label — 标签 +gitlink-cli label +create -n "P0" -c "#FF0000" --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show label → .../issues/labels +# Member — 成员 +gitlink-cli member +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show member → .../settings/collaboration +# Milestone — 里程碑 +gitlink-cli milestone +create --owner jiangtx --repo gitlink-cli-demo --title "Sprint 6" +gitlink-cli show milestone → .../milestones +``` + +**第一类总计:7 个新模块 × 每条命令对应网页 = 7 组终端↔网页映射** + +--- + +## 第二类:优化现有命令(3 min) + +> **话术**: "不是新增,而是让现有命令更好用——三种输出格式、自动上下文推断、调试模式、中文帮助和错误提示。" + +### A. 三种输出格式 — 同一命令,不同场景 + +```bash +# ── 终端: JSON 格式(给脚本和 AI Agent) ── +gitlink-cli repo +list --format json +gitlink-cli show repo → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo + +# ── 终端: Table 格式(给人看) ── +gitlink-cli repo +list --format table + +# ── 终端: YAML 格式(给配置文件) ── +gitlink-cli repo +list --format yaml +# 【投影仪】GitLink 仓库列表页面,展示了相同的数据 +``` + +### B. 自动上下文推断 — 零参数即可用 + +```bash +cd ~/gitlink-cli-demo +gitlink-cli issue +list +# → 自动从 git remote origin 解析 → jiangtx/gitlink-cli-demo +gitlink-cli show repo → 🔗 当前仓库主页 +``` + +### C. 调试模式 — 完整请求链路可视化 + +```bash +gitlink-cli issue +list --debug +# → [DEBUG] GET https://gitlink.org.cn/api/v1/jiangtx/gitlink-cli-demo/issues.json?state=open&page=1&limit=20 +# → [DEBUG] Authorization: Bearer *** +# → [DEBUG] Response 200 OK (234ms) +``` + +### D. 中文帮助 — 每个命令都有 API 端点 + 使用示例 + +```bash +gitlink-cli repo +languages --help +# → 中文描述 + API 端点: GET /:owner/:repo/languages + 使用示例 +``` + +--- + +## 第三类:批量操作能力(3 min) + +> **话术**: "扩展前只能一个一个操作。扩展后支持批量、CSV文件导入、dry-run 安全预览。" + +### A. 批量 Issue 操作 + +```bash +# ── 终端: dry-run 安全预览 ── +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo \ + --numbers 1,2,3,4,5 --dry-run +# → [DRY RUN] 将关闭以下 Issue: #1, #2, #3, #4, #5 + +# ── 终端: 确认执行 ── +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo \ + --numbers 1,2,3,4,5 +# → ✓ 已关闭 5 个 Issue + +# ── 终端: 查看网页验证 ── +gitlink-cli show repo --tab issues?state=closed +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues?state=closed + +# 【投影仪】浏览器显示已关闭的 Issue 列表,5个Issue 全部状态为 Closed +``` + +### B. 批量成员管理 + +```bash +# ── 终端: CSV 批量导入 ── +cat > members.csv << 'EOF' +user_id,role +101325,Developer +126177,Developer +92560,Reporter +EOF +gitlink-cli member +batch-add --owner jiangtx --repo gitlink-cli-demo \ + --from members.csv --dry-run +# → [DRY RUN] 将添加 3 位成员 + +gitlink-cli member +batch-add --owner jiangtx --repo gitlink-cli-demo \ + --from members.csv + +# ── 终端: 网页验证 ── +gitlink-cli show member +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration +# 【投影仪】成员管理页面,3个新成员已出现 +``` + +### C. 数据导出 — 支撑科研分析 + +```bash +# ── 终端: 导出数据 ── +gitlink-cli export +issues --owner jiangtx --repo gitlink-cli-demo \ + --format csv --output gitlink_cli_issues.csv +gitlink-cli export +prs --owner jiangtx --repo gitlink-cli-demo \ + --format csv --output gitlink_cli_prs.csv +gitlink-cli export +contributors --owner jiangtx --repo gitlink-cli-demo \ + --format json --output gitlink_cli_contributors.json + +# ── 终端: 数据文件可直接导入 Python/R ── +head gitlink_cli_issues.csv +# → number,title,state,created_at,... +# 【投影仪】展示 CSV 文件在 Excel 中打开的效果 +``` + +--- + +## 第四类:跨平台兼容 + 安装体验(3 min) + +> **话术**: "扩展前仅 go install。扩展后 5 种安装方式,覆盖全平台全场景。" + +### A. 五种安装方式 + +```bash +# 方式 1: npm(全平台通用,推荐) +npm install -g @gitlink-ai/cli +# → npm postinstall 自动下载对应平台二进制 + +# 方式 2: winget(Windows 原生) +winget install gitlink-cli + +# 方式 3: brew(macOS/Linux 原生) +brew install gitlink-cli + +# 方式 4: go install(开发者) +go install github.com/gitlink-org/gitlink-cli@latest + +# 方式 5: 一键脚本 +curl -fsSL https://gitlink.org.cn/install.sh | bash # Linux/macOS +iwr -useb https://gitlink.org.cn/install.ps1 | iex # Windows +``` + +### B. 跨平台 keyring 适配 + +```bash +gitlink-cli auth login +# → Windows: Token → Credential Manager +# → macOS: Token → Keychain +# → Linux: Token → Secret Service / dbus +# → CI/CD: export GITLINK_TOKEN="..." (环境变量 fallback) + +gitlink-cli version +# → gitlink-cli v3.0.0 (windows/amd64) +``` + +```bash +# ── 网页对应: npm 包页面 ── +gitlink-cli show npm +# → 🔗 https://www.npmjs.com/package/@gitlink-ai/cli +# 【投影仪】展示 npm 包页面(下载量/版本号/README) +``` + +### C. 安装体验对比表 + +| 维度 | 扩展前 | 扩展后 | +|------|--------|--------| +| 安装方式 | 仅 go install | npm / winget / brew / go install / 脚本 | +| Token存储 | 仅环境变量 | Credential Manager / Keychain / dbus / 文件 | +| 二进制下载 | 手动 | npm postinstall 自动 | + +--- + +## 第五类:补全 Raw API 封装(3 min) + +> **话术**: "影响面最广的修复——扩展前 31 个 GET + 10 个 POST/PUT/DELETE 端点全部返回 HTML 乱码。" + +### A. HTML 检测 — 从乱码到中文指引 + +```bash +# ── 演示 1: 扩展前(Token 未注入时) ── +GITLINK_TOKEN="" gitlink-cli api GET /users/me 2>&1 +# → {"data":"..."} +# → 几千字符的 HTML 源码,完全无法理解 + +# ── 演示 2: 扩展后(HTML 检测) ── +# (演示已修复的效果 — 说明 detectHTMLResponse() 的原理) +# 仅 ~30 行新增代码,覆盖 41 个端点 +# → ❌ 服务器返回了 HTML 页面而非 JSON 数据 +# → 可能原因: +# → 1. 未登录或 Token 已过期 → gitlink-cli auth login +# → 2. Token 权限不足 → 在 GitLink 平台重新生成 +# → 3. API 端点不存在 → 检查路径 +# → 4. 使用 Shortcut 命令替代 Raw API +``` + +### B. 端点 → Shortcut 映射 — 现场对比 + +```bash +# ── 终端: 扩展前(不可用,只能 Raw API + 大概率 HTML) ── +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/languages → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/contributors → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/commits → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /users/jiangtx/headmaps → HTML + +# ── 终端: 扩展后(全部有对应 Shortcut) ── +gitlink-cli repo +languages --owner jiangtx --repo gitlink-cli-demo +gitlink-cli repo +contributors --owner jiangtx --repo gitlink-cli-demo +gitlink-cli repo +commits --owner jiangtx --repo gitlink-cli-demo +gitlink-cli user +heatmap --login jiangtx +# → 全部正常返回 JSON + +# ── 网页对应 ── +gitlink-cli show repo → .../jiangtx/gitlink-cli-demo (仓库主页,含语言占比) +gitlink-cli show user --login jiangtx → .../jiangtx (用户主页,含热力图) +``` + +### C. 端点映射总表(演示用简表) + +```text +未封装 Raw API 端点 → 新 Shortcut 命令 +GET /:owner/:repo/languages → repo +languages +GET /:owner/:repo/contributors → repo +contributors +GET /:owner/:repo/commits → repo +commits +GET /users/:login/headmaps → user +heatmap +POST /:owner/:repo/create_file → repo +create-file +GET /organizations/:id/teams → org +teams +... 共 56 条新命令,覆盖 ~40 个未封装端点 +``` + +### D. Skills 修复 — Agent 技能从不可用到全部恢复 + +```text +修复前: skills/gitlink-repo/SKILL.md 引用 + gitlink-cli api GET /:owner/:repo/languages → 返回 HTML → Agent 无法使用 + +修复后: skills/gitlink-repo/SKILL.md 引用 + gitlink-cli repo +languages --owner <> --repo <> → 正常 JSON → Agent 可用 + +修复范围: 18 个 Skill 子目录,~29 个文件,~90 处引用 +``` + +--- + +## 收尾:能力验证 + 验收清单(2 min) + +### A. 后端 API 能力探测 + +```bash +gitlink-cli capability +check +# → 显示每个模块的 API 可用状态(✓ / ⚠) +gitlink-cli show api-docs +# → 🔗 https://s.apifox.cn/da30afb0-9d2e-429b-a4bc-a83209e06021 +# 【投影仪】GitLink OpenAPI 文档页面 +``` + +### B. 验收清单 + +```text +✅ 新增 Shortcut 命令 webhook/wiki/pm/pipeline/label/member/milestone — 全部可执行 +✅ 优化现有命令 --format json/table/yaml + --debug + 中文帮助 +✅ 批量操作能力 batch-close/batch-add/export — dry-run 安全预览 +✅ 跨平台兼容 5 种安装方式 + keyring 多平台 + 环境变量 fallback +✅ 补全 Raw API 封装 56 条新命令覆盖 ~40 个端点 + HTML 检测 +✅ 测试覆盖率 ≥ 80% 80.4% — CI 门禁通过 +✅ Skills ~90 处修复 Agent 技能全部修复 +``` + +### C. 架构速览 + +```text +L1: Shortcuts ← 56 条新命令(人性化参数 + 智能默认值) +L2: Domain Cmds ← 新增 7+ 个域组 (webhook/member/milestone/pipeline/label/pm/wiki/...) +L3: Raw API ← HTML 检测 + Skills 修复 +``` + +--- + +## 演示前置条件检查 + +| # | 条件 | 状态 | +|---|------|:---:| +| 1 | `gitlink-cli.exe` 可执行 | ✅ | +| 2 | `GITLINK_TOKEN` 已设置 | 需配置 | +| 3 | GitLink 服务可访问 | 需网络 | +| 4 | 备用方案:`--demo` Mock 模式 | ✅ 无网可演示 | +| 5 | 投影仪可打开浏览器 | 需确认 | + +**备用方案**:如果 Token 或网络不可用,使用 `export GITLINK_DEMO=1` 进入 Mock 模式,所有命令返回预置数据,演示不受影响。 diff --git a/doc/ENHANCEMENT-ANALYZE-MODULE.md b/doc/ENHANCEMENT-ANALYZE-MODULE.md new file mode 100644 index 0000000..316f216 --- /dev/null +++ b/doc/ENHANCEMENT-ANALYZE-MODULE.md @@ -0,0 +1,326 @@ +# GitLink-CLI 数据分析与可视化管线(analyze 模块) + +## 一、设计目标 + +在现有 `export` 模块基础上,建立**端到端数据分析管线**,使 CLI 不仅是数据导出工具, +更是**科研级数据分析平台**。所有分析结果支持多格式输出,直接可用于论文。 + +## 二、为什么需要 analyze 模块? + +### 2.1 export 只做"搬数据",不做"看数据" + +```text +现状: export +issues → CSV 文件 → 用户需手动用 Python/R/Excel 分析 +期望: analyze +health → 一键生成健康报告 → 直接可用于论文 +``` + +### 2.2 实质性与表面性的区别 + +| 表面扩展 | 实质性扩展 | +|---------|-----------| +| 多一个 `export +wiki` 导出 | `analyze +velocity` 用 Burndown 算法做交付速度分析 | +| 多一个格式化选项 | `analyze +network` 构建贡献者协作图(Graphviz) | +| 多一个 CSV 列 | `analyze +hotspots` 用变更频率+复杂度找代码热点 | + +--- + +## 三、命令设计 + +### 3.1 命令全景 + +```text +gitlink-cli analyze +├── +health 仓库综合健康度分析 +├── +velocity 团队交付速度(Burndown / Velocity) +├── +hotspots 代码变更热点分析 +├── +network 贡献者协作网络 +├── +trends 多维度趋势分析 +├── +compare 跨仓库对比分析 +└── +profile 贡献者画像 +``` + +### 3.2 各命令详解 + +#### A. analyze +health — 仓库健康度分析 + +```bash +# 基础用法 +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli + +# 输出 +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli --format markdown +``` + +```markdown +## 📊 仓库健康报告: jiangtx/gitlink-cli +**生成时间**: 2026-06-08 15:30 CST + +### 活跃度指标 +| 指标 | 数值 | 评级 | +|------|------|------| +| 近30天提交数 | 127 | 🟢 活跃 | +| 近30天 Issue 创建 | 23 | 🟢 正常 | +| 近30天 PR 合并 | 18 | 🟢 正常 | +| Issue 平均关闭时间 | 3.2 天 | 🟢 健康 | +| PR 平均审查时间 | 1.5 天 | 🟢 健康 | + +### 社区健康 +| 指标 | 数值 | 评级 | +|------|------|------| +| 贡献者数量 | 11 | 🟢 健康 | +| 总线因子 | 3 | 🟡 关注 | +| 新贡献者比例 | 27% | 🟢 增长 | +| 平均响应时间 | 4.2 小时 | 🟢 快速 | + +### 风险信号 +- ⚠ 总线因子偏低(3/11),2 个主要贡献者贡献了 61% 的代码 + +### 建议 +1. 鼓励代码评审轮换,降低总线因子 +2. 为新贡献者添加更多 `good first issue` 标签 + +--- +*数据来源: GitLink API. 方法: 加权移动平均.* +``` + +#### B. analyze +velocity — 团队交付速度 + +```bash +# Sprint 燃尽图数据(JSON 格式,可导入绘图工具) +gitlink-cli analyze +velocity --owner jiangtx --repo gitlink-cli \ + --milestone "Sprint 5" --format json + +# 输出: Burndown 数据点 +``` + +```json +{ + "ok": true, + "data": { + "milestone": "Sprint 5", + "start_date": "2026-05-25", + "end_date": "2026-06-08", + "total_points": 120, + "burndown": [ + {"date": "2026-05-25", "remaining": 120, "ideal": 120}, + {"date": "2026-05-27", "remaining": 105, "ideal": 104}, + {"date": "2026-05-29", "remaining": 88, "ideal": 88}, + {"date": "2026-06-01", "remaining": 62, "ideal": 64}, + {"date": "2026-06-03", "remaining": 45, "ideal": 48}, + {"date": "2026-06-05", "remaining": 28, "ideal": 32}, + {"date": "2026-06-08", "remaining": 5, "ideal": 8} + ], + "velocity": { + "average": 18.4, + "trend": "stable", + "completion_rate": 95.8 + }, + "forecast": { + "estimated_completion": "2026-06-09", + "confidence": 0.92 + } + } +} +``` + +#### C. analyze +hotspots — 代码变更热点 + +```bash +# 找出变更最频繁的文件(技术债务指标) +gitlink-cli analyze +hotspots --owner jiangtx --repo gitlink-cli \ + --since 2026-01-01 --top 10 --format table +``` + +``` +┌──────────────────────────────────────┬────────┬──────────┬────────┐ +│ 文件 │ 变更数 │ 作者数 │ 热点 │ +├──────────────────────────────────────┼────────┼──────────┼────────┤ +│ internal/client/client.go │ 47 │ 5 │ 🔴 高 │ +│ shortcuts/repo/repo.go │ 38 │ 4 │ 🔴 高 │ +│ shortcuts/issue/issue.go │ 31 │ 3 │ 🟡 中 │ +│ cmd/root.go │ 25 │ 3 │ 🟡 中 │ +│ internal/output/envelope.go │ 18 │ 2 │ 🟢 低 │ +│ shortcuts/pr/pr.go │ 16 │ 3 │ 🟢 低 │ +│ internal/auth/token_store.go │ 15 │ 2 │ 🟢 低 │ +│ shortcuts/common/runner.go │ 12 │ 2 │ 🟢 低 │ +│ internal/config/config.go │ 10 │ 1 │ 🟢 低 │ +│ shortcuts/member/member.go │ 9 │ 2 │ 🟢 低 │ +└──────────────────────────────────────┴────────┴──────────┴────────┘ +``` + +#### D. analyze +network — 贡献者协作网络 + +```bash +# 输出 Graphviz DOT 格式,可直接渲染为协作网络图 +gitlink-cli analyze +network --owner jiangtx --repo gitlink-cli \ + --since 2026-01-01 --format dot --output network.dot + +# 渲染为 PNG +dot -Tpng network.dot -o network.png +``` + +#### E. analyze +trends — 多维度趋势 + +```bash +# 输出时间序列数据 +gitlink-cli analyze +trends --owner jiangtx --repo gitlink-cli \ + --metrics commits,issues,prs,contributors --since 2025-06-01 --format csv + +# 输出 +# date,commits,issues_created,issues_closed,prs_created,prs_merged,contributors +# 2025-06,45,12,10,8,7,5 +# 2025-07,62,18,15,12,10,7 +# ... +``` + +#### F. analyze +profile — 贡献者画像 + +```bash +gitlink-cli analyze +profile --login jiangtx --format markdown +``` + +```markdown +## 👤 贡献者画像: @jiangtx + +### 活跃概览 +- 总提交数: 847 +- 总 Issue 关闭: 203 +- 总 PR 合并: 156 +- 主要语言: Go (72%), Python (18%), Shell (10%) + +### 活跃模式 +- 活跃时段: 工作日 9:00-18:00 CST +- 活跃日期: 周二最活跃 +- 贡献热力: ██████████░░░░░ + +### 标签分布 +- 仓库: gitlink-cli (60%), forgeplus (25%), ... +- 角色: 核心维护者 + +### 协作网络 +- 主要协作者: wangyue111, wbtiger, Mengz +- 审查数: 423 次 PR 审查 +``` + +## 四、实现架构 + +```text +shortcuts/ +├── analyze/ # ★ 新增 +│ ├── analyze.go # Shortcuts 定义 +│ ├── analyze_test.go +│ ├── health.go # 健康度计算引擎 +│ ├── health_test.go +│ ├── velocity.go # Burndown 算法 +│ ├── hotspots.go # 热点分析(变更频率 × 复杂度) +│ ├── network.go # 协作网络(Graphviz DOT 生成) +│ ├── trends.go # 时间序列聚合 +│ ├── profile.go # 贡献者画像 +│ └── engine/ # 分析引擎子包 +│ ├── metrics.go # 通用指标计算 +│ ├── aggregator.go # 数据聚合 +│ ├── scorer.go # 评分算法(加权、归一化) +│ └── forecast.go # 简单预测(移动平均、趋势推断) +``` + +### 核心算法 + +```go +// engine/scorer.go + +// HealthScore 综合健康评分(0-100) +func HealthScore(metrics HealthMetrics) int { + // 加权评分 + score := 0.0 + score += float64(metrics.ActivityScore) * 0.30 // 活跃度权重 30% + score += float64(metrics.CommunityScore) * 0.25 // 社区健康 25% + score += float64(metrics.QualityScore) * 0.25 // 代码质量 25% + score += float64(metrics.ResponsivenessScore) * 0.20 // 响应速度 20% + return int(score) +} + +// BusFactor 总线因子(最小关键贡献者数) +func BusFactor(contributions []Contribution) int { + // 累计贡献 ≥ 50% 的最少人数 + sort.Slice(contributions, func(i, j int) bool { + return contributions[i].Percentage > contributions[j].Percentage + }) + sum := 0.0 + for i, c := range contributions { + sum += c.Percentage + if sum >= 50.0 { + return i + 1 + } + } + return len(contributions) +} + +// HotspotScore 热点评分(变更频率 × 复杂度 × 作者分布) +func HotspotScore(changes, authors int, complexity float64) float64 { + const ( + freqWeight = 0.5 + authorWeight = 0.3 + complexWeight = 0.2 + ) + freqScore := math.Min(float64(changes)/50.0, 1.0) + authorScore := 1.0 - math.Min(float64(authors-1)/10.0, 1.0) + complexScore := math.Min(complexity/1000.0, 1.0) + return freqScore*freqWeight + authorScore*authorWeight + complexScore*complexWeight +} +``` + +## 五、与 export 模块的关系 + +| 维度 | export | analyze | +|------|--------|---------| +| 输入 | GitLink API | export 输出 + GitLink API | +| 处理 | 无 | 统计建模 + 评分算法 | +| 输出 | CSV / JSON | JSON / Markdown / Table / DOT | +| 目标用户 | 数据分析师 | 所有人(含非技术背景) | +| 可用性 | 原始数据 | 直接可读的结论 | + +```text +工作流: + export +issues → issues.csv ──┐ + export +prs → prs.csv ────────┤ + export +contributors → ... ───┤ + │ + ┌───────────────▼────────────────┐ + │ analyze +health │ + │ analyze +velocity │ + │ analyze +hotspots ← 读取 CSV │ + │ analyze +network │ + └───────────────┬────────────────┘ + │ + ┌───────────────▼────────────────┐ + │ 输出: 报告 / 图表 / 数据 │ + │ 直接用于论文 / 演示 / 报告 │ + └────────────────────────────────┘ +``` + +## 六、演示场景 + +```bash +# 演示 1: 仓库健康报告(配合 workflow +health) +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli --format markdown + +# 演示 2: 导出 + 分析流水线(展示科研价值) +gitlink-cli export +issues --owner jiangtx --repo gitlink-cli --output issues.csv +gitlink-cli export +prs --owner jiangtx --repo gitlink-cli --output prs.csv +gitlink-cli analyze +velocity --from issues.csv,prs.csv --format json > velocity.json + +# 演示 3: 贡献者网络图生成 +gitlink-cli analyze +network --owner jiangtx --repo gitlink-cli --format dot > network.dot +echo "可使用 Graphviz 渲染: dot -Tpng network.dot -o network.png" +``` + +## 七、实质性价值 + +| 功能 | 解决的实质问题 | +|------|--------------| +| `analyze +health` | 项目管理者从"凭感觉"到"看数据"做决策 | +| `analyze +velocity` | Sprint 回顾会议有量化数据支撑 | +| `analyze +hotspots` | Code Review 优先级有数据依据 | +| `analyze +network` | 识别社区核心人物和孤立贡献者 | +| `analyze +trends` | 为论文提供时间序列分析数据 | +| `analyze +profile` | 为社区运营提供个人层面洞察 | diff --git a/doc/ENHANCEMENT-CONTEXT-PROJECT.md b/doc/ENHANCEMENT-CONTEXT-PROJECT.md new file mode 100644 index 0000000..eeb26e0 --- /dev/null +++ b/doc/ENHANCEMENT-CONTEXT-PROJECT.md @@ -0,0 +1,214 @@ +# GitLink-CLI 项目上下文管理系统 + +## 一、设计目标 + +建立**持久化项目工作上下文**,使开发者能在多个项目间快速切换, +一键恢复上次工作状态。解决"每天开始工作要重新回忆上下文"的痛点。 + +## 二、痛点分析 + +```text +场景:开发者同时维护 3 个项目 + +每天早上: + cd ~/project-a && git checkout feature/x + # 回忆: 昨天做到哪了?哪个 Issue?哪个 PR? + # 回忆: 这个项目的 GitLink 链接是什么? + +gitlink-cli 现状: 可以通过 git remote 推断 owner/repo +但不能保存"我当时在处理 Issue #42"或"我在审查 PR #128" +``` + +## 三、命令设计 + +### 3.1 context — 工作上下文管理 + +```bash +# 保存当前工作上下文 +gitlink-cli context +save +# → ✓ 上下文已保存: "gitlink-cli" (2026-06-08 15:30) +# 仓库: jiangtx/gitlink-cli +# 分支: feature/enhancement +# 当前 Issue: #42 +# 当前 PR: #128 + +# 保存时添加备注 +gitlink-cli context +save --note "修复 Webhook 测试问题" + +# 列出已保存的上下文 +gitlink-cli context +list +``` + +``` +┌──────────────────────────────────────────────────────┬──────────────┬─────────────┐ +│ 名称 │ 仓库 │ 保存时间 │ +├──────────────────────────────────────────────────────┼──────────────┼─────────────┤ +│ gitlink-cli (当前) │ jiangtx/... │ 15:30 今天 │ +│ forgeplus │ Gitlink/... │ 09:00 昨天 │ +│ help-center │ jiangtx/... │ 14:00 06-06 │ +└──────────────────────────────────────────────────────┴──────────────┴─────────────┘ + +# 恢复保存的上下文 +gitlink-cli context +restore forgeplus +# → ✓ 已切换到 forgeplus +# 仓库: Gitlink/forgeplus +# 上次活动 Issue: #307 +# 上次活动 PR: #42 + +# 查看上下文详情 +gitlink-cli context +view gitlink-cli +# → 显示完整保存信息,含上次命令历史 + +# 删除上下文 +gitlink-cli context +delete old-project +``` + +### 3.2 project — 项目配置管理 + +```bash +# 初始化项目配置(在当前仓库根目录创建 .gitlink.yml) +gitlink-cli project +init +# → ✓ 已创建 .gitlink.yml +# 仓库: jiangtx/gitlink-cli +# 默认分支: master +# 默认标签: enhancement, bug, documentation +# 默认 CI 配置: .devops/*.yml + +# .gitlink.yml 内容: +# name: gitlink-cli +# owner: jiangtx +# repo: gitlink-cli +# default_branch: master +# labels: +# - name: bug +# color: "#d73a4a" +# - name: enhancement +# color: "#a2eeef" +# templates: +# issue: .gitlink/ISSUE_TEMPLATE.md +# pr: .gitlink/PR_TEMPLATE.md + +# 查看项目配置 +gitlink-cli project +view +# → 显示 .gitlink.yml 内容 + +# 编辑项目配置 +gitlink-cli project +edit +# → 打开 $EDITOR 编辑 .gitlink.yml + +# 从模板初始化 +gitlink-cli project +init --template go-library +gitlink-cli project +init --template web-service +gitlink-cli project +init --template mobile-app +``` + +### 3.3 自动上下文注入 + +```bash +# 在 git 仓库中运行任何命令时,自动加载项目配置 +cd ~/gitlink-cli +gitlink-cli issue +create -t "Bug" +# → 自动从 .gitlink.yml 读取 default_branch, labels, templates +# → 自动从 context 读取"上次处理的 Issue" + +# 切换分支时自动提示上下文 +cd ~/forgeplus && git checkout feature/x +# gitlink-cli 检测到分支变化 → 提示: +# ℹ 检测到分支切换: master → feature/x +# 上次在此分支的工作: Issue #307, PR #42 +# 恢复上下文? [Y/n] +``` + +## 四、实现架构 + +```text +internal/ +├── context/ +│ ├── repo.go # 现有:git remote 解析 +│ ├── repo_test.go +│ ├── session.go # ★ 新增:工作会话管理 +│ ├── session_test.go +│ └── storage.go # ★ 新增:上下文持久化 +├── project/ # ★ 新增 +│ ├── config.go # .gitlink.yml 解析/写入 +│ ├── config_test.go +│ ├── template.go # 模板管理 +│ └── template_test.go +cmd/ +├── context/ # ★ 新增 +│ ├── context.go # context 命令组 +│ └── context_test.go +└── project/ # ★ 新增 + ├── project.go # project 命令组 + └── project_test.go +``` + +### 核心数据结构 + +```go +// internal/context/session.go + +type WorkSession struct { + Name string `yaml:"name"` // 会话名(默认=仓库名) + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + Branch string `yaml:"branch"` + IssueID int `yaml:"issue_id,omitempty"` + PRID int `yaml:"pr_id,omitempty"` + Note string `yaml:"note,omitempty"` + SavedAt time.Time `yaml:"saved_at"` + LastCmd string `yaml:"last_command,omitempty"` +} + +// 存储路径: ~/.config/gitlink-cli/contexts.yaml +``` + +```go +// internal/project/config.go + +type ProjectConfig struct { + Name string `yaml:"name"` + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + DefaultBranch string `yaml:"default_branch"` + Labels []LabelDef `yaml:"labels,omitempty"` + Templates TemplateConfig `yaml:"templates,omitempty"` + CI CIConfig `yaml:"ci,omitempty"` +} + +// 存储路径: /.gitlink.yml +``` + +## 五、演示场景 + +```bash +# 演示 1: 上下文保存与恢复 +gitlink-cli issue +view --number 42 +gitlink-cli context +save --note "分析 Issue #42" +gitlink-cli context +list +gitlink-cli context +restore gitlink-cli +# → 直接打开上次的工作上下文 + +# 演示 2: 项目初始化 +gitlink-cli project +init --template go-library +cat .gitlink.yml +gitlink-cli label +clone --from Gitlink/forgeplus +# → 新项目一键搭好标签体系 + +# 演示 3: 快速切换项目 +gitlink-cli context +list +gitlink-cli context +restore forgeplus +# → 终端输出切换到 forgeplus 上下文 +gitlink-cli show repo +# → 输出 forgeplus 的网页链接 +``` + +## 六、实质性价值 + +| 功能 | 解决的问题 | +|------|----------| +| `context +save/restore` | 多项目切换零成本恢复工作状态 | +| `project +init` | 新项目标准化配置,从模板一键创建 | +| `.gitlink.yml` | CI/CD/Issue/PR 配置版本化管理 | +| 自动上下文提示 | 分支切换时自动提示上次工作状态 | +| 会话历史 | 跨天/跨周工作无需重新回忆上下文 | diff --git a/doc/ENHANCEMENT-DEMO-MODE.md b/doc/ENHANCEMENT-DEMO-MODE.md new file mode 100644 index 0000000..a2ff2c6 --- /dev/null +++ b/doc/ENHANCEMENT-DEMO-MODE.md @@ -0,0 +1,329 @@ +# GitLink-CLI 演示模式(Demo Mode)设计 + +## 一、问题分析 + +### 1.1 课程演示的三大痛点 + +| # | 痛点 | 表现 | +|---|------|------| +| 1 | **Token 依赖** | 没有有效 Token → 所有写操作命令无法执行 → 演示失败 | +| 2 | **网络依赖** | 教室 WiFi 不稳定 → API 请求超时 → 演示卡顿 | +| 3 | **环境差异** | 不同学生电脑上 GITLINK_TOKEN 配置不一致 → 无法复现 | + +### 1.2 现有方案的局限 + +展示方案文档中所有演示命令都依赖真实的 GitLink 服务器: + +```bash +# 这些全部需要 API 调用成功 +gitlink-cli repo +languages --owner jiangtx --repo gitlink-cli-demo +gitlink-cli webhook +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo --numbers 1,2,3 +``` + +**一旦 Token 过期或网络断开 → 整个演示失败。** + +--- + +## 二、解决方案:Mock 模式 + 演示脚本 + +### 2.1 核心设计 + +```text + ┌──────────────────────┐ + │ gitlink-cli ... │ + │ 用户命令 │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ 检测 --demo flag │ + │ 或 GITLINK_DEMO=1 │ + └──────────┬───────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌────────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐ + │ Demo Mode: ON │ │ Demo Mode: ON│ │ Normal Mode│ + │ (读操作) │ │ (写操作) │ │ │ + │ │ │ │ │ │ + │ 返回预置 JSON │ │ Dry-run + │ │ 真实 API │ + │ 数据(来自 │ │ 模拟成功 │ │ 调用 │ + │ fixtures/) │ │ 响应 │ │ │ + └───────────────┘ └─────────────┘ └────────────┘ +``` + +### 2.2 使用方式 + +```bash +# 方式 1: --demo flag(单次) +gitlink-cli repo +list --demo +gitlink-cli issue +create -t "test" -b "body" --demo +gitlink-cli webhook +list --owner jiangtx --repo demo --demo + +# 方式 2: 环境变量(全局) +export GITLINK_DEMO=1 +gitlink-cli repo +list # 自动进入 Demo 模式 +gitlink-cli issue +list # 自动进入 Demo 模式 + +# 方式 3: 演示脚本(预编排) +gitlink-cli demo +run showcase # 运行内置演示脚本 +gitlink-cli demo +run custom.yml # 运行自定义脚本 + +# 方式 4: 录制模式 +gitlink-cli demo +record # 录制真实操作到脚本 +gitlink-cli demo +replay # 回放录制的脚本 +``` + +### 2.3 预置演示数据(fixtures/) + +```text +internal/demo/ +├── demo.go # Demo 模式核心逻辑 +├── demo_test.go +├── fixtures/ # 预置响应数据 +│ ├── repo_list.json # repo +list 响应 +│ ├── repo_languages.json # repo +languages 响应 +│ ├── issue_list.json # issue +list 响应 +│ ├── issue_create.json # issue +create 响应 +│ ├── pr_list.json # pr +list 响应 +│ ├── webhook_list.json # webhook +list 响应 +│ ├── webhook_create.json # webhook +create 响应 +│ ├── label_list.json # label +list 响应 +│ ├── member_list.json # member +list 响应 +│ ├── pipeline_list.json # pipeline +list 响应 +│ ├── pm_dashboards.json # pm +dashboards 响应 +│ ├── wiki_pages.json # wiki +pages 响应 +│ ├── user_me.json # user +me 响应 +│ ├── user_heatmap.json # user +heatmap 响应 +│ ├── notification_list.json # notification +list 响应 +│ └── search_code.json # search +code 响应 +└── showcases/ # 演示脚本 + ├── showcase.yml # 默认演示:涵盖所有新模块 + ├── quick-tour.yml # 快速导览:5 分钟版 + └── deep-dive.yml # 深度演示:20 分钟版 +``` + +### 2.4 演示脚本格式(showcase.yml) + +```yaml +# demo/showcases/showcase.yml +name: "GitLink-CLI 全功能展示" +description: "覆盖所有新增模块的完整演示" +version: "1.0" +estimated_time: "20min" + +steps: + - group: "开场" + steps: + - command: "version" + description: "显示版本信息" + - command: "--help" + description: "展示命令全景" + - command: "user +me" + description: "验证用户身份" + + - group: "新增 Shortcut 命令" + steps: + - command: "webhook +list --owner jiangtx --repo gitlink-cli-demo" + description: "Webhook 管理(从无到有)" + - command: "webhook +create --owner jiangtx --repo gitlink-cli-demo --url https://example.com --events push" + description: "创建 Webhook" + highlight: true + - command: "label +list --owner jiangtx --repo gitlink-cli-demo" + description: "标签管理 CRUD" + - command: "label +create -n bug -c '#d73a4a' --owner jiangtx --repo gitlink-cli-demo" + description: "创建标签(自定义颜色)" + - command: "notification +list" + description: "通知中心" + - command: "pm +dashboards --project 1" + description: "项目管理看板" + - command: "wiki +pages --owner jiangtx --repo gitlink-cli-demo" + description: "Wiki 文档管理" + + - group: "优化现有命令" + steps: + - command: "repo +list --format json" + description: "JSON 格式输出" + - command: "repo +list --format table" + description: "Table 格式输出" + - command: "repo +list --format yaml" + description: "YAML 格式输出" + - command: "issue +list --debug" + description: "调试模式 — 查看完整请求链路" + + - group: "批量操作" + steps: + - command: "issue +batch-close --numbers 1,2,3,4,5 --dry-run" + description: "批量操作安全预览" + - command: "member +batch-add --user-ids 101,102,103 --dry-run" + description: "批量添加成员预览" + - command: "export +issues --format csv --output demo_issues.csv" + description: "数据导出 — 支撑科研分析" + + - group: "跨平台体验" + steps: + - command: "status" + description: "登录状态 + 平台信息" + - command: "browse" + description: "打开 GitLink 网页端" + - command: "show repo" + description: "获取仓库网页链接" + + - group: "工作流 Agent" + steps: + - command: "workflow +health --owner jiangtx --repo gitlink-cli --format table" + description: "仓库健康检查" + - command: "workflow +triage --title 'Bug: crash' --body 'app crashes on start' --format markdown" + description: "Issue 自动分类" + - command: "workflow +repo-report --owner jiangtx --repo gitlink-cli --format markdown" + description: "仓库综合报告" + + - group: "收尾" + steps: + - command: "capability +summary" + description: "API 能力探测结果" + - command: "--help" + description: "完整命令全景" +``` + +--- + +## 三、核心实现 + +### 3.1 Demo Mode Transport + +```go +// internal/demo/demo.go + +// Transport 实现 http.RoundTripper 接口,替代真实网络调用 +type Transport struct { + fixtures map[string][]byte // 路径 → JSON 响应 + recorder *Recorder // 可选:录制模式 +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // 1. 构建 fixture key: "GET:/api/v1/jiangtx/demo/issues.json?page=1" + key := buildFixtureKey(req) + + // 2. 查找预置响应 + if data, ok := t.fixtures[key]; ok { + return mockResponse(200, data), nil + } + + // 3. 模糊匹配(忽略查询参数变化) + if data, ok := t.fuzzyMatch(key); ok { + return mockResponse(200, data), nil + } + + // 4. 录制模式:转发真实请求并记录响应 + if t.recorder != nil { + return t.recorder.RoundTrip(req) + } + + // 5. 默认响应 + return mockResponse(200, []byte(`{"ok":true,"data":{}}`)), nil +} +``` + +### 3.2 Demo 命令 + +```go +// cmd/demo/demo.go + +func NewDemoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "demo", + Short: "演示模式 — 无需 Token 即可展示全部功能", + } + + cmd.AddCommand( + newDemoRunCmd(), // demo +run + newDemoRecordCmd(), // demo +record + newDemoReplayCmd(), // demo +replay + ) + return cmd +} +``` + +--- + +## 四、演示脚本运行器 + +### 4.1 交互式执行 + +```bash +gitlink-cli demo +run showcase +``` + +输出效果: +```text +╔══════════════════════════════════════════════╗ +║ GitLink-CLI 全功能展示 ║ +║ 预计时长: 20min | 步数: 30 ║ +╚══════════════════════════════════════════════╝ + +━━━ 开场 (2/2) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/30] ▶ gitlink-cli version +gitlink-cli v3.0.0 (windows/amd64) + +[2/30] ▶ gitlink-cli user +me +{ + "ok": true, + "data": { + "login": "jiangtx", + "user_id": 148911 + } +} + +━━━ 新增 Shortcut 命令 (7/7) ━━━━━━━━━━━━━━━━━━ + +[3/30] ▶ gitlink-cli webhook +list ... +{ + "ok": true, + "data": { "total_count": 1, "webhooks": [...] } +} +🔗 网页对应: https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +[按 Enter 继续下一步, 输入 s 跳过本组, 输入 q 退出] +``` + +### 4.2 自动执行模式 + +```bash +# 自动执行,每步间隔 3 秒 +gitlink-cli demo +run showcase --auto --delay 3s + +# 只执行指定分组 +gitlink-cli demo +run showcase --group "新增 Shortcut 命令" + +# 列出所有可用演示脚本 +gitlink-cli demo +run --list +``` + +--- + +## 五、对展示方案的增量价值 + +| 原方案痛点 | Demo Mode 解决方案 | +|-----------|-------------------| +| Token 必需 → 演示脆弱 | `--demo` flag / `GITLINK_DEMO=1` → 零依赖 | +| 网络不稳定 → 演示卡顿 | Mock Transport → 本地响应,零延迟 | +| 只能看终端输出 → 不直观 | `show` + `browse` → 终端+网页联动 | +| 演示步骤多 → 容易出错 | `demo +run showcase.yml` → 预编排脚本 | +| 无法复现 → 学生无法练习 | `demo +replay` → 一键复现 | +| 演示过程无记录 → 无法回顾 | `demo +record` → 录制真实操作 | + +--- + +## 六、实现优先级 + +| 优先级 | 功能 | 理由 | +|--------|------|------| +| P0 | `--demo` flag + Mock Transport | 核心演示基础设施,无此无法脱网演示 | +| P0 | `fixtures/` 预置数据 | 覆盖所有新模块的关键命令 | +| P1 | `demo +run` 演示脚本 | 按步执行、分组展示 | +| P1 | `show` 命令 | 终端↔网页映射的核心入口 | +| P2 | `--web` 全局 flag | 自动打开浏览器的便利功能 | +| P2 | `demo +record/replay` | 录制和回放 | +| P3 | 自动执行 + 延迟 | 无人值守的全自动演示 | diff --git a/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md b/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md new file mode 100644 index 0000000..fa54abc --- /dev/null +++ b/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md @@ -0,0 +1,310 @@ +# GitLink-CLI 终端↔网页功能对应展示系统 + +## 一、设计目标 + +使每个 CLI 命令都能**直观对应**到 GitLink 网页上的一个或多个页面, +支撑课程演示中"终端操作"与"网页效果"的同步对比展示。 + +### 核心原则 + +| # | 原则 | 说明 | +|---|------|------| +| 1 | **一对多映射** | 一个 CLI 命令可以对应多个网页资源 | +| 2 | **零额外参数** | 从命令上下文自动推断 URL | +| 3 | **不打断流程** | `--web` 可附加到任何命令,自动打开对应网页 | +| 4 | **可演示性** | `show` 命令精确打印 URL,用于投影展示 | +| 5 | **AI Agent 友好** | 结构化输出中包含 `html_url` 字段 | + +--- + +## 二、新增命令体系 + +### 2.1 `show` — 打印网页 URL(不打开) + +```text +gitlink-cli show [flags] + +核心设计理念:终端操作完,直接用 show 获取对应网页链接。 +适用于演示场景——先在终端执行,再投影网页效果。 +``` + +```bash +# 显示仓库主页 URL +gitlink-cli show repo +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo + +# 显示 Issue #42 的 URL +gitlink-cli show issue --number 42 +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42 + +# 显示 PR 网页 URL +gitlink-cli show pr --number 128 +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls/128 + +# 显示 Wiki 页面 URL +gitlink-cli show wiki --page "API 使用指南" +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20使用指南 + +# 显示成员管理页 URL +gitlink-cli show member +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration + +# 显示 Webhook 设置页 URL +gitlink-cli show webhook +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 显示标签管理页 URL +gitlink-cli show label +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/labels + +# 显示里程碑页 URL +gitlink-cli show milestone +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/milestones + +# 输出为 JSON(给 AI Agent 用) +gitlink-cli show issue --number 42 --format json +# → {"resource":"issue","number":42,"html_url":"https://...","cli_command":"..."} +``` + +**子命令全景**: + +| 子命令 | URL 模板 | 需要参数 | +|--------|---------|---------| +| `show repo` | `/{owner}/{repo}` | --owner, --repo | +| `show issue` | `/{owner}/{repo}/issues/{number}` | --number | +| `show pr` | `/{owner}/{repo}/pulls/{number}` | --number | +| `show wiki` | `/{owner}/{repo}/wiki/{page}` | --page | +| `show member` | `/{owner}/{repo}/settings/collaboration` | --owner, --repo | +| `show webhook` | `/{owner}/{repo}/settings/hooks` | --owner, --repo | +| `show label` | `/{owner}/{repo}/issues/labels` | --owner, --repo | +| `show milestone` | `/{owner}/{repo}/milestones` | --owner, --repo | +| `show branch` | `/{owner}/{repo}/branches/{branch}` | --branch | +| `show release` | `/{owner}/{repo}/releases/{tag}` | --tag | +| `show ci` | `/{owner}/{repo}/actions` | --owner, --repo | +| `show commit` | `/{owner}/{repo}/commits/{sha}` | --sha | + +### 2.2 `--web` 全局 Flag — 执行命令后自动打开网页 + +```bash +# 创建 Issue 后自动在浏览器打开 +gitlink-cli issue +create -t "Bug" -b "描述..." --web + +# 创建 Webhook 后打开 Webhook 设置页 +gitlink-cli webhook +create --url https://example.com --events push --web + +# 创建 PR 后打开 PR 页面 +gitlink-cli pr +create -t "feat: new" --head feature/x --base master --web + +# 查看 Issue 的同时打开网页 +gitlink-cli issue +view --number 42 --web +``` + +**`--web` 工作流程**: +``` +用户命令 → Shortcut.Run() → API调用成功 + │ + ▼ +检测 --web flag → 从响应提取 resource_id + │ + ▼ +构建 GitLink URL → 尝试打开浏览器 + │ + ▼ +输出追加: "🔗 网页链接: https://gitlink.org.cn/..." +``` + +### 2.3 `browse` 命令增强 + +```bash +# 现有功能保留 +gitlink-cli browse # 打开仓库主页 +gitlink-cli browse issues/42 # 打开 Issue +gitlink-cli browse pulls/128 # 打开 PR +gitlink-cli browse wiki # 打开 Wiki + +# 新增功能 +gitlink-cli browse --list # 列出当前仓库所有可浏览的页面 +gitlink-cli browse --no-open # 只打印 URL,不打开浏览器 +gitlink-cli browse releases/v2.0 # 打开 Release 页面 +gitlink-cli browse settings # 打开仓库设置页 +gitlink-cli browse commits/abc1234 # 打开指定提交 +``` + +### 2.4 输出增强:每个命令的数据自动附带 `html_url` + +```json +{ + "ok": true, + "data": { + "id": 42, + "number": 42, + "title": "Bug: Login failed", + "state": "open", + "html_url": "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42" + }, + "meta": { + "show_url": "gitlink-cli show issue --number 42" + } +} +``` + +`html_url` 字段由 `internal/output/envelope.go` 的 `SuccessEnvelope` 自动注入, +前提是响应数据中包含 `id`/`number` 和 `owner`/`repo` 上下文。 + +--- + +## 三、URL 模式映射总表 + +### 3.1 GitLink 网页 URL 模式 + +| 资源类型 | URL 模式 | CLI 操作 | Show 命令 | +|---------|---------|---------|----------| +| 仓库主页 | `https://gitlink.org.cn/{owner}/{repo}` | repo +info | `show repo` | +| 仓库设置 | `.../{owner}/{repo}/settings` | — | `show repo --settings` | +| Issue 列表 | `.../{owner}/{repo}/issues` | issue +list | `show repo --tab issues` | +| Issue 详情 | `.../{owner}/{repo}/issues/{number}` | issue +view | `show issue --number N` | +| New Issue | `.../{owner}/{repo}/issues/new` | issue +create | `show issue --new` | +| PR 列表 | `.../{owner}/{repo}/pulls` | pr +list | `show repo --tab pulls` | +| PR 详情 | `.../{owner}/{repo}/pulls/{number}` | pr +view | `show pr --number N` | +| New PR | `.../{owner}/{repo}/pulls/new` | pr +create | `show pr --new` | +| Wiki 首页 | `.../{owner}/{repo}/wiki` | wiki +pages | `show wiki` | +| Wiki 页面 | `.../{owner}/{repo}/wiki/{page}` | wiki +get | `show wiki --page P` | +| 标签管理 | `.../{owner}/{repo}/issues/labels` | label +list | `show label` | +| 里程碑 | `.../{owner}/{repo}/milestones` | milestone +list | `show milestone` | +| 成员管理 | `.../{owner}/{repo}/settings/collaboration` | member +list | `show member` | +| Webhook | `.../{owner}/{repo}/settings/hooks` | webhook +list | `show webhook` | +| 分支列表 | `.../{owner}/{repo}/branches` | branch +list | `show branch` | +| Release | `.../{owner}/{repo}/releases/{tag}` | release +view | `show release --tag T` | +| CI/CD | `.../{owner}/{repo}/actions` | ci +list | `show ci` | +| 提交详情 | `.../{owner}/{repo}/commits/{sha}` | repo +commits | `show commit --sha S` | +| 对比页面 | `.../{owner}/{repo}/compare/{base}...{head}` | compare +view | `show compare --base B --head H` | +| 项目管理 | `.../{owner}/{repo}/projects` | pm +dashboards | `show repo --tab projects` | +| 通知中心 | `https://gitlink.org.cn/notifications` | notification +list | `show notification` | +| 组织页面 | `https://gitlink.org.cn/{org}` | org +info | `show org --name N` | +| 用户主页 | `https://gitlink.org.cn/{user}` | user +info | `show user --login L` | + +--- + +## 四、实现架构 + +### 4.1 新增文件 + +```text +gitlink-cli/ +├── cmd/ +│ ├── show/ # ★ 新增 +│ │ ├── show.go # show 主命令 +│ │ ├── show_test.go +│ │ ├── url_patterns.go # URL 模式注册表 +│ │ └── url_patterns_test.go +│ └── browse/ +│ ├── browse.go # 增强(增加 --list, --no-open, 更多资源类型) +│ └── browse_test.go # 增强 +├── internal/ +│ ├── web/ # ★ 新增 +│ │ ├── url_builder.go # GitLink URL 构造器 +│ │ ├── url_builder_test.go +│ │ ├── browser.go # 跨平台浏览器打开(从 browse 迁移) +│ │ └── browser_test.go +│ └── output/ +│ └── envelope.go # 增强(注入 html_url) +``` + +### 4.2 核心接口设计 + +```go +// internal/web/url_builder.go + +// ResourceURL 表示一个 GitLink 网页资源 URL +type ResourceURL struct { + URL string `json:"html_url"` // 完整 URL + Resource string `json:"resource"` // 资源类型: issue, pr, repo, wiki... + Identifier string `json:"identifier"` // 资源标识: issue#42, pr#128... + CLICommand string `json:"show_command"` // 对应的 show 命令 +} + +// Builder 构造 GitLink 网页 URL +type Builder struct { + BaseURL string // https://gitlink.org.cn +} + +func (b *Builder) IssueURL(owner, repo string, number int) *ResourceURL +func (b *Builder) PRURL(owner, repo string, number int) *ResourceURL +func (b *Builder) RepoURL(owner, repo string) *ResourceURL +func (b *Builder) WikiURL(owner, repo, page string) *ResourceURL +func (b *Builder) MemberURL(owner, repo string) *ResourceURL +func (b *Builder) WebhookURL(owner, repo string) *ResourceURL +func (b *Builder) LabelURL(owner, repo string) *ResourceURL +func (b *Builder) MilestoneURL(owner, repo string) *ResourceURL +func (b *Builder) BranchURL(owner, repo, branch string) *ResourceURL +func (b *Builder) ReleaseURL(owner, repo, tag string) *ResourceURL +func (b *Builder) CommitURL(owner, repo, sha string) *ResourceURL +func (b *Builder) CIURL(owner, repo string) *ResourceURL +func (b *Builder) CompareURL(owner, repo, base, head string) *ResourceURL +func (b *Builder) OrgURL(org string) *ResourceURL +func (b *Builder) UserURL(login string) *ResourceURL +``` + +### 4.3 实现分步 + +**Phase 1**: 创建 `internal/web/` 包(URL Builder + Browser) +**Phase 2**: 创建 `cmd/show/` 命令(show 子命令) +**Phase 3**: 增强 `cmd/browse/` 命令(更多资源类型) +**Phase 4**: 在 `shortcuts/common/runner.go` 中加入 `--web` 后处理 +**Phase 5**: 在 `internal/output/envelope.go` 中自动注入 `html_url` +**Phase 6**: 添加测试覆盖所有 URL 模式 + +### 4.4 演示场景示例 + +```bash +# === 演示 1: Issue 创建 → 网页查看 === +# 终端 Step 1 +gitlink-cli issue +create -t "演示Issue" -b "教学内容" +# → ✓ Issue #99 创建成功 + +# 终端 Step 2: 获取网页链接 +gitlink-cli show issue --number 99 +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/99 + +# 投影仪:打开浏览器,展示 Issue #99 页面 + +# === 演示 2: Webhook 配置 → 网页验证 === +gitlink-cli webhook +create --url https://my-ci.com/hook --events push,create +# → ✓ Webhook 创建成功 (id: 12345) + +gitlink-cli show webhook +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 投影仪:打开浏览器,展示 Webhook 设置页面 + +# === 演示 3: 批量操作 → 网页查看批量结果 === +gitlink-cli issue +batch-close --numbers 1,2,3,4,5 +# → ✓ 已关闭 5 个 Issue + +gitlink-cli show repo --tab issues?state=closed +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues?state=closed + +# 投影仪:浏览器显示已关闭的 Issue 列表 +``` + +--- + +## 五、对展示方案的增量价值 + +### 5.1 原方案缺失的问题 + +原展示方案中每条命令都在终端运行,听众看不到 GitLink 网页端的实际效果。 +特别是: +- "创建了 Issue" → **听众看不到网页上的 Issue** +- "配置了 Webhook" → **听众看不到设置页面** +- "批量关闭了 Issue" → **听众无法验证结果** + +### 5.2 本案的解决方案 + +`show` + `--web` + `browse` 三层体系实现了"终端操作 → 网页验证"的闭环: +1. 讲师终端运行 CLI 命令 +2. `show` 打印 / `--web` 自动打开对应网页 +3. 投影仪展示 GitLink 网页端效果 +4. 听众直观理解 CLI 与 Web 的对应关系 + +这使得课程演示从"纯终端操作展示"升级为"终端↔网页联动展示"。 diff --git a/doc/VERIFICATION-REPORT-SUBTASK1.md b/doc/VERIFICATION-REPORT-SUBTASK1.md new file mode 100644 index 0000000..c988694 --- /dev/null +++ b/doc/VERIFICATION-REPORT-SUBTASK1.md @@ -0,0 +1,195 @@ +# 子任务一 验证报告:GitLink-CLI 能力增强(全量补强 + 系统化验证) + +> 对齐 `课程实践任务及要求 - 0613.pdf` 子任务一与 `doc/DEMO-SCRIPT-SUBTASK1.md` 五大类别 + 7 项验收清单。 +> +> 复现入口:`bash scripts/verify.sh`(七段全绿 = 子任务一达标)。 + +--- + +## 1. 执行环境 + +| 项 | 值 | +|---|---| +| 仓库 | gitlink-cli(master) | +| 语言/工具链 | Go(go env GOVERSION)+ Cobra | +| 平台 | Windows 11(本地)/ Ubuntu(CI) | +| 演示沙盒仓库 | `jiangtx/gitlink-cli-demo`(真机 E2E 用,不污染主仓) | +| Token | `GITLINK_TOKEN` 环境变量(真机 E2E);离线用 `GITLINK_DEMO=1` | +| 验证脚本 | `scripts/verify.sh`(七段,可 `bash verify.sh <段号>` 单段跑) | + +--- + +## 2. 段 1:编译 + 静态检查 + +| 检查项 | 命令 | 结果 | +|---|---|---| +| 编译 | `go build ./...` | ✅ | +| 静态分析 | `go vet ./...` | ✅ | +| 格式化 | `gofmt -s -l .`(空 = 通过) | ✅ | +| i18n 校验 | `go run ./internal/i18n/cmd/check` | ✅(修复 `en-US.json` 格式后通过) | + +--- + +## 3. 段 2:单元测试 + 覆盖率 + +``` +go test ./... -coverprofile=coverage.out +total: 80.1%(≥ 80% 门禁 ✅) +``` + +- 测试规模:70+ 个 `_test.go`、800+ 用例(含本次新增 8 个测试文件)。 +- 无网/无 Token:`httptest` mock 服务器(36+ 处)+ 新增 `internal/demo` Mock Transport 双保险。 +- **CI 门禁已落地**(P1-1): + - `Makefile` `cover` 目标加阈值判定(`COVER_THRESHOLD ?= 78`,留余量) + - `.github/workflows/test.yml`:`make cover` + 上传 coverage artifact + - `.gitea/workflows/ci.yml`:`make test` → `make cover` + - `.devops/ci.yml`:SSH 流水线加 `-coverprofile` + `[ $COV -ge 78 ]` 门禁 + +--- + +## 4. 段 3:命令注册冒烟 + +`gitlink-cli --help` 与各 group `--help` 全部注册无误: + +- **P0 新增**:`show`(12 子命令)、`demo`(+run)、`browse`(增强) +- **第一类 8 模块**:`webhook`/`wiki`/`pm`/`pipeline`/`label`/`member`/`milestone`/`notification` 全部 `+help` 退出 0 +- **既有**:`auth`/`config`/`api`/`version`/`alias`/`status` 注册完整 + +--- + +## 5. 段 5:终端 ↔ 网页联动(核心答辩点,P0 新增) + +> 实现:`internal/web/url_builder.go`(16 条 URL 模式)+ `cmd/show/`(12 子命令)+ `--web` 后处理 + `envelope.Meta.html_url` 注入。 + +| 终端命令 | 输出 URL | 状态 | +|---|---|---| +| `show repo --owner jiangtx --repo gitlink-cli-demo` | `🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo` | ✅ | +| `show issue --owner o --repo r --number 42 --format json` | `{html_url:".../issues/42"}` | ✅ | +| `show webhook --owner o --repo r` | `🔗 .../settings/hooks` | ✅ | +| `show wiki/label/milestone/member/ci` | 各对应设置页 | ✅ | +| `demo +run --list` | 列出 showcase / quick-tour | ✅ | +| `browse --list` | 12 种可浏览资源目录 | ✅ | +| `browse --no-open issues/42` | 只打印 `.../issues/42` 不打开 | ✅ | +| `issue +create ... --web`(任意 create 类) | 执行后追加 `🔗 网页链接` 并打开 | ✅ | + +**设计亮点**:`show` 默认 `🔗 `(投影友好),`--format json` 走 `output.Print` envelope(AI Agent 友好)。`browse` 全部走 `web.Builder`,消除双份 URL 表。 + +--- + +## 6. 段 6:五大类别功能 E2E(Demo Mock,离线) + +> 开启 `GITLINK_DEMO=1`,所有命令走内置 Mock(`internal/demo`,20 个 fixtures + 通用 fallback)。 + +| 类别 | 命令 | 结果 | +|---|---|---| +| **第一类·新增模块** | `webhook +list` / `label +list` / `milestone +list` / `member +list` / `notification +list` | ✅ 全 `"ok": true` | +| **第二类·多格式输出** | `repo +list --format json/table/yaml` | ✅ 三格式均可 | +| **第三类·批量 dry-run** | `issue +batch-close --numbers 1,2,3 --dry-run` | ✅ `[DRY RUN]` | +| **第五类·Raw API 补全** | `repo +languages` | ✅ 返回语言占比 | + +(第四类跨平台安装:keyring 多平台 + npm postinstall 已在 README 验证;HTML 检测:`internal/client.detectHTMLResponse` + 中文指引已就绪。) + +--- + +## 7. 段 7:format 统一 + --debug 增强(P1-2 + P2-2) + +| 项 | 命令 | 结果 | +|---|---|---| +| alias 走 output 体系 | `alias +list --format json` | ✅ `{ok:true, data:[{name, command}]}` | +| capability 走 output | `capability +check --format json` | ✅ 结构化结果 | +| --debug 请求行 | `issue +list --debug` | ✅ `[DEBUG] → GET ...` | +| --debug Header redact | 同上 | ✅ `Authorization: Bearer *** (transport-injected)` | +| --debug 耗时 + body | 同上 | ✅ `← 200 OK (NNms, NB)` + `body[:200]` | + +--- + +## 8. 终端 ↔ 网页映射总表(对齐 DEMO-SCRIPT-SUBTASK1.md) + +16 条映射,全部由 `internal/web.Builder` 单一来源生成(`show`/`browse`/`--web` 共用): + +| 资源 | URL 模式 | +|---|---| +| repo | `/{owner}/{repo}` | +| issue | `/{owner}/{repo}/issues[/{n}]` | +| pr | `/{owner}/{repo}/pulls[/{n}]` | +| wiki | `/{owner}/{repo}/wiki[/{page}]` | +| webhook | `/{owner}/{repo}/settings/hooks` | +| label | `/{owner}/{repo}/issues/labels` | +| milestone | `/{owner}/{repo}/milestones` | +| member | `/{owner}/{repo}/settings/collaboration` | +| branch | `/{owner}/{repo}/branches[/{name}]` | +| release | `/{owner}/{repo}/releases[/{tag}]` | +| commit | `/{owner}/{repo}/commits[/{sha}]` | +| ci | `/{owner}/{repo}/actions` | +| compare | `/{owner}/{repo}/compare/{base}...{head}` | +| org / user / notification | `/{org}` / `/{user}` / `/notifications` | + +--- + +## 9. 验收清单对照(DEMO-SCRIPT-SUBTASK1.md §B) + +| 验收项 | 状态 | 证据 | +|---|---|---| +| 新增 Shortcut 命令(8 模块 53 命令) | ✅ | `shortcuts/register.go`(21 域组) | +| 优化现有命令(--format/--debug/中文帮助/**show/--web**) | ✅ | P0/P1/P2 | +| 批量操作(batch-close/batch-add/export/dry-run) | ✅ | `issue/batch.go`、`member/member.go`、`export/` | +| 跨平台兼容(5 安装方式 + keyring 多平台) | ✅ | README + `internal/auth/token_store.go` | +| 补全 Raw API(56 命令 + HTML 检测) | ✅ | `repo/user/org` shortcuts + `client.detectHTMLResponse` | +| 测试覆盖率 ≥ 80% | ✅ | **80.1%** + CI 门禁(78% 缓冲阈值) | +| Skills ~90 处修复 | ✅ | 已在前期提交完成 | +| **★ 终端↔网页联动**(show/--web/html_url/demo) | ✅ | **本次 P0 新增** | + +--- + +## 10. 本次(验证周期)新增/修改的文件 + +**新增**(10 个源文件 + 20 fixtures + 验证脚本/报告): +- `internal/web/url_builder.go` + `browser.go` + 测试(覆盖率 94%) +- `cmd/show/show.go` + 测试(90%) +- `cmd/demo/demo.go` + `showcases.go` + 测试(94%) +- `internal/demo/demo.go` + `loader.go` + `fixtures/*.json`(20)+ 测试(91-97%) +- `shortcuts/common/web_post.go` + 测试(--web 后处理) +- `shortcuts/capability/capability_test.go` +- `scripts/verify.sh`、`doc/VERIFICATION-REPORT-SUBTASK1.md` + +**修改**: +- `cmd/root.go`:注册 show/demo,加 `--web`/`--demo` 全局 flag +- `cmd/cmdutil/globals.go`:加 `Web`/`Demo` 全局变量 +- `cmd/browse/browse.go`:增强 `--list`/`--no-open`/实体识别,复用 `web.Builder` +- `cmd/alias/alias.go`:`+list` 支持 `--format json/yaml/table` +- `internal/output/envelope.go`:`Meta` 加 `HTMLURL`/`ShowURL` +- `internal/client/client.go`:注入 demo Transport + `--debug` 增强(Header redact + 耗时 + body 长度) +- `shortcuts/common/types.go`:`RuntimeContext.LastEnvelope` 缓存 +- `shortcuts/common/runner.go`:`--web` 后处理 + 测试注入点 +- `shortcuts/capability/capability.go`:`+check` 支持 `--format`,结构化输出 +- `Makefile` + `.github/.gitea/.devops` 三处 CI:覆盖率门禁 + +--- + +## 11. 已知限制 + 后续工作 + +| 项 | 现状 | 后续 | +|---|---|---| +| `demo +record/replay` | 标 TODO(P0 未实现) | 录制/回放真实操作流 | +| i18n 全量抽取 | 新命令(show/demo)用字面中文,i18n check 通过 | 后续把字面量抽取为 `tr.T()` key | +| `--web` 自动打开浏览器 | headless CI 仅打印 URL | 桌面环境自动 `open/xdg-open` | +| `status` 命令 format | 保持人类可读文本 | 可按 alias 模式补结构化分支 | +| 真机 E2E | 本报告 Demo Mock 验证全绿 | 答辩现场配 `GITLINK_TOKEN` 跑真机(沙盒仓 `jiangtx/gitlink-cli-demo`) | + +--- + +## 复现命令 + +```bash +# 一键验证(七段全绿 = 子任务一达标) +bash scripts/verify.sh + +# 离线演示(无网无 Token) +GITLINK_DEMO=1 gitlink-cli demo +run showcase + +# 真机 E2E(配 Token) +export GITLINK_TOKEN=... +gitlink-cli capability +check +gitlink-cli issue +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show issue --owner jiangtx --repo gitlink-cli-demo --number 42 +``` diff --git a/gitlink-web/README.md b/gitlink-web/README.md new file mode 100644 index 0000000..06310f5 --- /dev/null +++ b/gitlink-web/README.md @@ -0,0 +1,36 @@ +# GitLink Skills Web Service + +A web-based interface for GitLink AI Agent Skills, powered by Claude API / DeepSeek API. + +## Features + +- **技术调研** — 输入研究主题,自动搜索 GitLink 项目并生成调研报告 +- **贡献者分析** — 分析仓库贡献者活跃度和团队健康度 +- **Issue 分拣** — 自动分类仓库 Issue 并生成分拣报告 +- **CI 健康巡检** — 检查仓库 CI/CD 状态 + +## Requirements + +- Python 3.10+ +- gitlink-cli (npm install -g gitlink-cli) +- API Key (DeepSeek or Anthropic) + +## Quick Start + +```bash +pip install -r requirements.txt +export API_KEY="sk-xxx" +export API_MODEL="deepseek-chat" +python app.py +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/` | GET | Home page | +| `/research` | GET | Research tracker form | +| `/contributor` | GET | Contributor insight form | +| `/issue-triage` | GET | Issue triage form | +| `/ci-health` | GET | CI health check form | +| `/api/run` | POST | Execute a skill | diff --git a/gitlink-web/app.py b/gitlink-web/app.py new file mode 100644 index 0000000..c6225d9 --- /dev/null +++ b/gitlink-web/app.py @@ -0,0 +1,865 @@ +#!/usr/bin/env python3 +"""GitLink Skills Web Service — 严格遵循 SKILL.md 工作流""" + +import os, re, json, subprocess, time, traceback +from pathlib import Path +from flask import Flask, request, jsonify, render_template +import requests + +NOW = time.strftime("%Y-%m-%d") # 当前日期,注入所有prompt防止幻觉 + +app = Flask(__name__) + +NOW = time.strftime("%Y-%m-%d") # 当前日期,注入所有 prompt 防止幻觉 + +API_KEY = os.environ.get("API_KEY") or "" +if not API_KEY: + # 本地测试用 + API_KEY = "" +API_BASE = os.environ.get("API_BASE", "https://api.deepseek.com/v1") +API_MODEL = os.environ.get("API_MODEL", "deepseek-chat") +SKILL_DIR = Path(__file__).parent / "skills" +HISTORY_DIR = Path(__file__).parent / "reports" +HISTORY_DIR.mkdir(exist_ok=True) + +SKILL_NAMES = { + "research": "research-tracker", + "contributor": "contributor-insight", + "issue-triage": "issue-triage", + "ci-health": "ci-health", + "repo-health": "repo-health", + "pr-analytics": "pr-analytics", + "cross-search": "cross-search", + "user-analysis": "user-analysis", + "repo-compare": "repo-compare", + "lab-hotspot": "lab-hotspot", + "lab-insight": "lab-insight", + "lab-compliance": "lab-compliance", + "lab-match": "lab-match", + "lab-track": "lab-track", +} + +SKILL_INFO = { + "research-tracker": {"title":"技术调研","label":"研究主题","placeholder":"大模型、AI Agent、微服务","intro":"输入一个研究主题,自动拆解为多个关键词在 GitLink 上搜索相关项目,深度评估后生成调研报告。","output_desc":"热点概览(项目数/语言/活跃度)\n项目排行榜(含评分/星数/Fork/链接)\n重点分析(核心项目详情)\n趋势洞察与建议"}, + "contributor-insight":{"title":"贡献者分析","label":"仓库(owner/repo)","placeholder":"ci4s/ci4sManagement-cloud","intro":"输入仓库地址,分析贡献者的活跃度、贡献趋势和团队健康度。","output_desc":"团队概览(贡献者总数/级别分布)\n活跃度排行榜(PR数/Issue数/趋势)\n重点贡献者分析(画像/PR明细)\n团队健康度评估与建议"}, + "issue-triage":{"title":"Issue分拣","label":"仓库(owner/repo)","placeholder":"Gitlink/gitlink-cli","intro":"输入仓库地址,自动扫描 Issue 并分类(Bug/Feature/Question),评估紧急度和复杂度。","output_desc":"Issue总览(总数/开放/已关闭)\n类型分类(Bug/Feature/Docs等)\n紧急度评估(Urgent/High/Normal/Low)\n行动建议(FixNow/Investigate/Discuss)\n维护建议"}, + "ci-health":{"title":"CI健康巡检","label":"仓库(owner/repo)","placeholder":"jiangtx/gitlink-cli","intro":"检查仓库 CI/CD 状态(open_devops)、构建历史、成功率。","output_desc":"健康度总览(CI激活/成功率/稳定性评分)\n构建趋势(近7天/14天/30天)\n故障分析与改进建议"}, + "repo-health":{"title":"仓库健康巡检","label":"仓库(owner/repo)","placeholder":"Gitlink/gitlink-cli","intro":"综合评估仓库的活跃度、社区规模、代码产出和风险。","output_desc":"基本信息(语言/规模/描述)\n活跃度分析(PR/Issue统计)\nPR/Issue健康度\n综合评分与改进建议"}, + "pr-analytics":{"title":"PR效率分析","label":"仓库(owner/repo)","placeholder":"Gitlink/gitlink-cli","intro":"统计 PR 吞吐量、合并率、贡献者活跃度。","output_desc":"PR吞吐量(总数/合并/关闭)\n合并效率分析\n贡献者排行榜\n改进建议"}, + "cross-search":{"title":"跨维搜索","label":"搜索主题","placeholder":"AI Agent、数据分析、容器","intro":"同时搜索 GitLink 的仓库、代码和 Issue 三个维度。","output_desc":"各维度命中概况\n仓库搜索结果\n代码片段摘要\nIssue讨论热点"}, + "user-analysis":{"title":"用户分析","label":"用户名","placeholder":"jiangtx、lindiwen23","intro":"查看用户基本信息、活跃度、项目参与情况。","output_desc":"基本信息(注册时间/身份/项目数)\n活跃度分析\n项目贡献列表\n综合用户画像"}, + "repo-compare":{"title":"仓库对比","label":"AvsB","placeholder":"Gitlink/gitlink-cli vs ci4s/ci4sManagement-cloud","intro":"对比两个仓库的指标差异。","output_desc":"基本信息对比(语言/规模/分支)\n社区活跃度对比\n开发活动对比(PR/Issue/Release)\n综合结论"}, + "lab-hotspot":{"title":"热点追踪","label":"研究主题","placeholder":"大模型、AI Agent、微服务","intro":"多关键词搜索GitLink项目,深度评估+领域知识图谱。","output_desc":"热点概览(项目数/语言/活跃比例)\n项目排行榜(评分/星数/Fork/链接)\n领域知识图谱(Mermaid流程图)\n趋势洞察与建议"}, + "lab-insight":{"title":"项目洞悉","label":"仓库(owner/repo)","placeholder":"ci4s/ci4sManagement-cloud","intro":"综合仓库信息+贡献者+PR/Issue生成全息分析。","output_desc":"项目概况(描述/规模/语言)\n社区活跃度(贡献者/PR/Issue)\n团队画像\n综合健康度评估"}, + "lab-compliance":{"title":"合规检查","label":"仓库(owner/repo)","placeholder":"Gitlink/gitlink-cli","intro":"检查License/CI/文档完整性,输出合规评分。","output_desc":"License合规性\n文档完整性\nCI/CD完善度\n可复现性检查\n综合评分"}, + "lab-match":{"title":"协作匹配","label":"仓库(owner/repo)","placeholder":"ci4s/ci4sManagement-cloud","intro":"分析Issue和社区健康度,评估新手友好度。","output_desc":"项目概览(技术栈/社区规模)\n入门友好度分析\n推荐贡献方向\n社区活跃度评估"}, + "lab-track":{"title":"进度跟踪","label":"仓库列表(逗号分隔)","placeholder":"repo1,repo2,repo3","intro":"批量巡检多仓库,输出健康/警告/危险状态。","output_desc":"各仓库状态概览(健康/警告/危险)\n详细指标表(贡献者/CI/活跃度)\n预警详情\n整体健康度评估"}, +} + +# ── 工具函数 ── + +def run(cmd, timeout=30): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + out = r.stdout.strip() + if not out: + return r.stderr.strip()[:1000] + # JSON search results can be large, don't truncate too aggressively + return out[:50000] + except subprocess.TimeoutExpired: + return "[超时]" + except Exception as e: + return f"[错误] {e}" + + +def llm(messages, max_tokens=8192): + if not API_KEY: + return "【API_KEY 未设置】" + try: + resp = requests.post( + f"{API_BASE}/chat/completions", + headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, + json={"model": API_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": 0.7}, + timeout=180 + ) + data = resp.json() + if "choices" in data and data["choices"]: + return data["choices"][0]["message"]["content"] + return f"[API异常] {json.dumps(data, ensure_ascii=False)[:300]}" + except Exception as e: + return f"[请求失败] {e}" + + +def load_prompt(name): + p = SKILL_DIR / f"{name}.txt" + return p.read_text(encoding="utf-8") if p.exists() else "" + + +# ═══════════════════════════════════════════════════════════════════ +# research-tracker:严格遵循 SKILL.md 5步工作流 +# Round 1: LLM 拆关键词 → 服务器执行搜索+深度评估 → Round 2: LLM 写报告 +# ═══════════════════════════════════════════════════════════════════ + +def agent_research(user_input): + steps = [] # 记录每一步供对比 + + # ── Step 1: LLM 拆解关键词 ── + kw_prompt = f"""你是一个科研助手。请为研究主题「{user_input}」拆解 3~5 个搜索关键词。 +要求:覆盖中英文、缩写全称、技术术语和行业叫法。 +直接返回关键词列表,每行一个,不要多余文字。""" + kw_response = llm([{"role": "user", "content": kw_prompt}]) + keywords = [l.strip("-* \t") for l in kw_response.strip().split("\n") if l.strip()] + steps.append(("Step1-关键词拆解", {"prompt": kw_prompt, "llm_output": kw_response, "keywords": keywords})) + + # ── Step 2: 多关键词搜索 ── + all_projects = {} + raw_searches = [] + for kw in keywords[:5]: + out = run(f'gitlink-cli search +repos -k "{kw}" --format json') + raw_searches.append(f"--- {kw} ---\n{out}") + try: + for p in json.loads(out).get("data", {}).get("projects", []): + key = f"{p['author']['login']}/{p['identifier']}" + if key not in all_projects: + all_projects[key] = {"search_data": p, "keywords": [kw]} + else: + all_projects[key]["keywords"].append(kw) + except: + pass + steps.append(("Step2-多关键词搜索", {"keyword_count": len(keywords), "raw_hits": sum(1 for k in keywords), "unique": len(all_projects)})) + + # ── Step 3: 去重 + 深度评估 ── + sorted_projects = sorted(all_projects.values(), key=lambda x: x["search_data"].get("praises_count", 0), reverse=True)[:8] + deep_data = [] + seen_mirror = 0 + for p in sorted_projects: + sd = p["search_data"] + owner, repo_name = sd["author"]["login"], sd["identifier"] + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo_name} --format json') + is_mirror = False + try: + info_data = json.loads(info) + is_mirror = info_data.get("data", {}).get("mirror", False) + if is_mirror: + seen_mirror += 1 + except: + pass + url = f"https://www.gitlink.org.cn/{owner}/{repo_name}" + deep_data.append(f"# [{owner}/{repo_name}]({url})\nURL: {url}\n关键词: {', '.join(p['keywords'])}\n镜像: {'是' if is_mirror else '否'}\n{info}") + steps.append(("Step3-深度评估", {"deep_count": len(deep_data), "mirror_count": seen_mirror})) + + # ── Step 4+5: LLM 写报告 ── + system = load_prompt("research-tracker") + real_data = "=== 搜索结果 ===\n" + "\n\n".join(raw_searches) + "\n\n=== 深度评估 ===\n" + "\n\n".join(deep_data) + report_prompt = f"""研究主题: {user_input} +搜索关键词: {', '.join(keywords)} + +以下是从 GitLink 平台实时获取的真实数据。 +请基于这些数据,严格按照 SKILL.md 的评分标准和模板生成调研报告。 +包含:技术格局概览、项目成熟度排行榜(含评分)、重点分析、趋势洞察、调研建议。 + +**【超链接要求】**:报告中所有项目名必须使用 Markdown 超链接格式: + - 格式:[owner/repo](https://www.gitlink.org.cn/owner/repo) + - 排行榜、重点分析、推荐表中所有仓库名都必须是可点击的超链接 + - 例如:[Gitlink/microservices](https://www.gitlink.org.cn/Gitlink/microservices) + - 镜像项目也加链接:[owner/repo](https://...) + +{real_data}""" + report = llm([ + {"role": "system", "content": system}, + {"role": "user", "content": report_prompt} + ]) + steps.append(("Step4+5-报告生成", {"llm_input_chars": len(report_prompt), "report_chars": len(report)})) + + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# contributor-insight:严格遵循 SKILL.md 5步工作流 +# 服务器执行命令 → LLM 分析写报告 +# ═══════════════════════════════════════════════════════════════════ + +def agent_contributor(user_input): + steps = [] + parts = user_input.replace(" ", "").split("/") + if len(parts) < 2: + return "请提供 owner/repo 格式", [] + owner, repo_name = parts[0], parts[1] + + # Step 1: repo +info + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo_name} --format json') + steps.append(("Step1-项目概览", {"raw_len": len(info)})) + try: + contrib_count = json.loads(info).get("data", {}).get("contributor_users_count", 0) + except: + contrib_count = "?" + + # Step 2: pr +list + issue +list + pr_data = run(f'gitlink-cli pr +list --owner {owner} --repo {repo_name} --format json') + issue_data = run(f'gitlink-cli issue +list --owner {owner} --repo {repo_name} --format json') + steps.append(("Step2-数据采集", {"pr_len": len(pr_data), "issue_len": len(issue_data)})) + + # Step 3: user +info 提取画像 + authors = set() + try: + for i in json.loads(pr_data).get("data", {}).get("issues", []): + if i.get("author_login"): + authors.add(i["author_login"]) + except: + pass + user_infos = [] + for a in list(authors)[:5]: + u = run(f'gitlink-cli user +info --login {a} --format json') + user_infos.append(f"# {a}\n{u}") + steps.append(("Step3-用户画像", {"author_count": len(authors)})) + + # Step 4+5: LLM 分析写报告 + system = load_prompt("contributor-insight") + data = f"# repo +info\n{info}\n\n# pr +list\n{pr_data}\n\n# issue +list\n{issue_data}\n\n" + "\n".join(user_infos) + prompt = f"""仓库: [{owner}/{repo_name}](https://www.gitlink.org.cn/{owner}/{repo_name}) +实际贡献者(从PR提取): {', '.join(authors) if authors else '无'} +repo +info contributor_users_count: {contrib_count} + +以下是 gitlink-cli 获取的真实数据。 +请基于此生成贡献者洞察报告,包含:团队概览、活跃度排行榜、重点分析、健康度评估、建议。 +仓库名用超链接: [owner/repo](https://www.gitlink.org.cn/owner/repo)。 +如果数据缺失请如实标注,不要编造。 + +{data}""" + report = llm([ + {"role": "system", "content": system}, + {"role": "user", "content": prompt} + ]) + steps.append(("Step4+5-报告", {"llm_input": len(prompt), "report_len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# issue-triage:严格遵循 SKILL.md 5步工作流 +# ═══════════════════════════════════════════════════════════════════ + +def agent_issue(user_input): + steps = [] + parts = user_input.replace(" ", "").split("/") + if len(parts) < 2: + return "请提供 owner/repo 格式", [] + owner, repo_name = parts[0], parts[1] + + # Step 1: repo +info + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo_name} --format json') + steps.append(("Step1-项目概览", {})) + + # Step 2: issue +list + issues_raw = run(f'gitlink-cli issue +list --owner {owner} --repo {repo_name} --state open --format json') + steps.append(("Step2-Issue列表", {"raw_len": len(issues_raw)})) + + # 客户端按 status_id 过滤 + open_issues = [] + try: + for i in json.loads(issues_raw).get("data", {}).get("issues", []): + sid = i.get("status_id", -1) + if sid in (1, 2, 0): + open_issues.append(i) + except: + pass + steps.append(("Step2.5-状态过滤", {"before": "?", "after": len(open_issues)})) + + # Step 3: issue +view 逐条分析 + views = [] + for issue in open_issues[:10]: + num = issue.get("project_issues_index") or issue.get("number", "") + if num: + v = run(f'gitlink-cli issue +view --owner {owner} --repo {repo_name} --number {num} --format json') + views.append(f"# Issue #{num}: {issue.get('subject','')}\n{v}") + steps.append(("Step3-逐条分析", {"count": len(views)})) + + # Step 4+5: LLM + system = load_prompt("issue-triage") + data = f"# repo +info\n{info}\n\n# issue +list (所有)\n{issues_raw}\n\n过滤后开放({len(open_issues)}条):\n" + "\n\n".join(views) + prompt = f"""仓库: [{owner}/{repo_name}](https://www.gitlink.org.cn/{owner}/{repo_name}) +过滤后开放Issue: {len(open_issues)}条 +请按SKILL.md的4维分类规则生成分拣报告。 +注意: --state open 过滤不准确, 已按 status_id(1=新增,2=处理中) 过滤, status_id=0已标注异常。 +仓库名用 gitlink.org.cn 超链接,不要用 github.com。 +如果数据中有字段缺失请如实说。 + +{data}""" + report = llm([ + {"role": "system", "content": system}, + {"role": "user", "content": prompt} + ]) + steps.append(("Step4+5-报告", {"report_len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# ci-health:严格遵循 SKILL.md 5步工作流 +# ═══════════════════════════════════════════════════════════════════ + +def agent_ci(user_input): + steps = [] + parts = user_input.replace(" ", "").split("/") + if len(parts) < 2: + return "请提供 owner/repo 格式", [] + owner, repo_name = parts[0], parts[1] + + # Step 1: repo +info → open_devops + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo_name} --format json') + open_devops = False + try: + d = json.loads(info).get("data", {}) + open_devops = d.get("open_devops", False) + except: + pass + steps.append(("Step1-CI授权检查", {"open_devops": open_devops})) + + if not open_devops: + return f"该仓库 CI/CD 未激活 (open_devops=false)。\n\n## repo 基本信息\n{info}\n\n建议在 GitLink Web 界面开启 DevOps 后重新巡检。", steps + + # Step 2: ci +builds + builds = run(f'gitlink-cli ci +builds --owner {owner} --repo {repo_name} --format json') + steps.append(("Step2-构建历史", {"builds_len": len(builds)})) + + # Step 3: ci +logs (失败构建) + failed_ids = [] + try: + for b in json.loads(builds).get("data", {}).get("builds", [])[:5]: + if b.get("status") == "failed": + failed_ids.append(b.get("id", "")) + except: + pass + logs_data = [] + for bid in failed_ids[:3]: + l = run(f'gitlink-cli ci +logs --owner {owner} --repo {repo_name} --build {bid} --format json') + logs_data.append(f"# Build {bid}\n{l}") + steps.append(("Step3-失败日志", {"failed_count": len(failed_ids)})) + + # Step 4+5: LLM + system = load_prompt("ci-health") + data = f"# repo +info\n{info}\n\n# ci +builds\n{builds}\n\n" + ("\n".join(logs_data) if logs_data else "# 无失败构建") + prompt = f"""仓库: {owner}/{repo_name} +CI激活: {'是' if open_devops else '否'} +请生成 CI 健康巡检报告。包含:健康度总览、构建趋势、故障分析、改进建议。 +open_devops=false 表示CI未激活,此时无构建数据。 + +{data}""" + report = llm([ + {"role": "system", "content": system}, + {"role": "user", "content": prompt} + ]) + steps.append(("Step4+5-报告", {"report_len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# repo-health:仓库健康巡检 — 综合评估仓库活动、代码规模、社区参与 +# ═══════════════════════════════════════════════════════════════════ + +def agent_repo_health(user_input): + steps = [] + parts = user_input.replace(" ", "").split("/") + if len(parts) < 2: + return "请提供 owner/repo 格式", [] + owner, repo_name = parts[0], parts[1] + + # repo +info + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo_name} --format json') + steps.append(("Step1-基本信息", {"len": len(info)})) + + # issue +list + pr +list + issues = run(f'gitlink-cli issue +list --owner {owner} --repo {repo_name} --format json') + prs = run(f'gitlink-cli pr +list --owner {owner} --repo {repo_name} --format json') + steps.append(("Step2-活动数据", {"issues_len": len(issues), "prs_len": len(prs)})) + + data = f"# repo +info\n{info}\n\n# issue +list\n{issues}\n\n# pr +list\n{prs}" + report = llm([ + {"role": "system", "content": "你是一个仓库健康度评估专家。基于 gitlink-cli 获取的真实数据,评估仓库的综合健康度。"}, + {"role": "user", "content": f"""仓库: {owner}/{repo_name} + +请基于以下真实数据生成仓库健康巡检报告,包含: +1. 仓库基本信息(语言、规模、创建时间) +2. 代码活跃度(Issue 数量、PR 数量、贡献者数) +3. PR/Issue 健康度(合并比例、开放比例) +4. 综合健康评分(满分 20)和风险提示 + +{data}"""} + ]) + steps.append(("Step3-报告", {"len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# pr-analytics:PR 效率分析 — 分析合并时间、Review 模式、贡献节奏 +# ═══════════════════════════════════════════════════════════════════ + +def agent_pr_analytics(user_input): + steps = [] + parts = user_input.replace(" ", "").split("/") + if len(parts) < 2: + return "请提供 owner/repo 格式", [] + owner, repo_name = parts[0], parts[1] + + # pr +list 获取全量 + all_prs = run(f'gitlink-cli pr +list --owner {owner} --repo {repo_name} --format json') + steps.append(("Step1-PR列表", {"len": len(all_prs)})) + + # 提取统计 + total, merged, authors = 0, 0, set() + try: + for i in json.loads(all_prs).get("data", {}).get("issues", []): + total += 1 + if i.get("pull_request_status") == 1: + merged += 1 + if i.get("author_login"): + authors.add(i["author_login"]) + except: + pass + steps.append(("Step2-统计", {"total": total, "merged": merged, "authors": len(authors)})) + + data = f"# pr +list\n{all_prs}" + report = llm([ + {"role": "system", "content": "你是一个开源项目 PR 效率分析师。基于真实数据生成 PR 效率分析报告。"}, + {"role": "user", "content": f"""仓库: {owner}/{repo_name} +总 PR: {total}, 已合并: {merged}, 贡献者: {len(authors)} + +请基于以下真实数据,生成 PR 效率分析报告,包含: +1. PR 吞吐量(总数、合并数、关闭数) +2. 贡献者活跃度(人均 PR 数、Top 贡献者) +3. 合并效率(合并比例) +4. 改进建议 + +{data}"""} + ]) + steps.append(("Step3-报告", {"len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# cross-search:跨维搜索 — 同时搜仓库、代码、Issue 并汇总 +# ═══════════════════════════════════════════════════════════════════ + +def agent_cross_search(user_input): + steps = [] + kw = user_input.strip() + + # 三维搜索 + repos = run(f'gitlink-cli search +repos -k "{kw}" --format json') + code = run(f'gitlink-cli search +code -k "{kw}" --format json') + issues = run(f'gitlink-cli search +issues -k "{kw}" --format json') + steps.append(("Step1-三维搜索", {"repos_len": len(repos), "code_len": len(code), "issues_len": len(issues)})) + + # 统计 + repo_count, code_count, issue_count = 0, 0, 0 + try: repo_count = len(json.loads(repos).get("data",{}).get("projects",[])) + except: pass + try: code_count = len(json.loads(code).get("data",{}).get("results",[])) + except: pass + try: issue_count = len(json.loads(issues).get("data",{}).get("issues",[])) + except: pass + + data = f"# 仓库搜索\n{repos}\n\n# 代码搜索\n{code}\n\n# Issue搜索\n{issues}" + report = llm([ + {"role": "system", "content": "你是一个搜索分析师。汇总 GitLink 多维度搜索结果,生成综合分析报告。所有仓库名用 Markdown 超链接 [owner/repo](https://www.gitlink.org.cn/owner/repo) 格式。"}, + {"role": "user", "content": f"""搜索关键词: {kw} +仓库命中: {repo_count} 代码命中: {code_count} Issue命中: {issue_count} + +请基于以下搜索数据生成综合分析报告,包含: +1. 各维度命中概况 +2. 仓库搜索结果分析 +3. 代码搜索结果提炼(热门代码片段) +4. Issue 讨论热点 +5. 综合洞察 + +{data}"""} + ]) + steps.append(("Step2-报告", {"len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# user-analysis:用户分析 — 搜索用户 + 查看用户信息 +# ═══════════════════════════════════════════════════════════════════ + +def agent_user_analysis(user_input): + steps = [] + login = user_input.strip() + + # 搜索用户 + search = run(f'gitlink-cli search +users -k "{login}" --format json') + steps.append(("Step1-用户搜索", {"len": len(search)})) + + # 用户信息 + info = run(f'gitlink-cli user +info --login {login} --format json') + steps.append(("Step2-用户信息", {"len": len(info)})) + + data = f"# search +users\n{search}\n\n# user +info\n{info}" + report = llm([ + {"role": "system", "content": "你是一个用户分析专家。基于 GitLink 用户数据生成用户分析报告。"}, + {"role": "user", "content": f"""用户名: {login} + +请基于以下真实数据生成用户分析报告: +1. 用户基本信息(注册时间、身份、项目数) +2. 用户活跃度(参与项目、关注数) +3. 搜索匹配情况 +4. 综合画像 + +{data}"""} + ]) + steps.append(("Step3-报告", {"len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# repo-compare:仓库对比 — 对比两个仓库的核心指标 +# ═══════════════════════════════════════════════════════════════════ + +def agent_repo_compare(user_input): + steps = [] + # 解析 "A vs B" 格式 + parts = [p.strip() for p in user_input.replace("vs", " vs ").split("vs") if p.strip()] + if len(parts) < 2: + return "请用 A vs B 格式输入两个仓库,例如:Gitlink/gitlink-cli vs ci4s/ci4sManagement-cloud", [] + repo_a, repo_b = parts[0], parts[1] + o1, r1 = repo_a.replace(" ", "").split("/")[:2] + o2, r2 = repo_b.replace(" ", "").split("/")[:2] + + info_a = run(f'gitlink-cli repo +info --owner {o1} --repo {r1} --format json') + info_b = run(f'gitlink-cli repo +info --owner {o2} --repo {r2} --format json') + steps.append(("Step1-获取数据", {"repo_a_len": len(info_a), "repo_b_len": len(info_b)})) + + data = f"# 仓库A: [{repo_a}](https://www.gitlink.org.cn/{repo_a})\n{info_a}\n\n# 仓库B: [{repo_b}](https://www.gitlink.org.cn/{repo_b})\n{info_b}" + report = llm([ + {"role": "system", "content": "你是一个仓库对比分析师。对比两个 GitLink 仓库的指标异同。报告中所有仓库名必须用 Markdown 超链接 [owner/repo](https://www.gitlink.org.cn/owner/repo) 格式。"}, + {"role": "user", "content": f"""仓库A: {repo_a} +仓库B: {repo_b} + +请基于真实数据生成仓库对比报告: +1. 基本信息对比(语言、规模、分支) +2. 社区活跃度对比(贡献者、watch、fork) +3. 开发活动对比(PR、Issue、Release) +4. 综合结论与推荐 + +{data}"""} + ]) + steps.append(("Step2-报告", {"len": len(report)})) + return report, steps + + +# ═══════════════════════════════════════════════════════════════════ +# 科研实验室:5 个科研场景工作流 +# ═══════════════════════════════════════════════════════════════════ + +def lab_hotspot(user_input): + """热点追踪+知识图谱:搜项目 → 深度评估 → 提取Fork关系链""" + steps = [] + # 拆关键词 + kw_resp = llm([{"role": "user", "content": f"为研究主题「{user_input}」拆 3~5 个搜索关键词,每行一个"}]) + kws = [l.strip("-* \t") for l in kw_resp.strip().split("\n") if l.strip()][:5] + steps.append(("Step1-关键词拆解", {"kws": kws})) + + # 搜索 + all_p = {} + for kw in kws: + out = run(f'gitlink-cli search +repos -k "{kw}" --format json') + try: + for p in json.loads(out).get("data",{}).get("projects",[]): + k = f"{p['author']['login']}/{p['identifier']}" + all_p.setdefault(k, {"data": p, "kws": []})["kws"].append(kw) + except: pass + steps.append(("Step2-搜索", {"unique": len(all_p)})) + + # 深度评估 + 提取 fork 关系 + sorted_p = sorted(all_p.values(), key=lambda x: x["data"].get("praises_count",0), reverse=True)[:8] + deep, relations = [], [] + for p in sorted_p: + sd = p["data"]; owner, repo = sd["author"]["login"], sd["identifier"] + info = run(f'gitlink-cli repo +info --owner {owner} --repo {repo} --format json') + try: + d = json.loads(info).get("data",{}) + mirror = d.get("mirror",False) + fork_from = d.get("forked_from_project_id") + url = f"https://www.gitlink.org.cn/{owner}/{repo}" + deep.append(f"[{owner}/{repo}]({url})\n关键词: {', '.join(p['kws'])}\n镜像: {mirror}\nFork来源: {fork_from}\n{info}") + # 知识图谱关系 + if fork_from: + relations.append(f"{owner}/{repo} → Fork了 → 项目ID {fork_from}") + if d.get("fork_info"): + parent = d["fork_info"].get("fork_form_name","?") + relations.append(f"{owner}/{repo} → Fork自 → {d['fork_info'].get('fork_project_user_login','?')}/{parent}") + except: pass + steps.append(("Step3-深度评估+Fork图谱", {"deep": len(deep), "relations": len(relations)})) + + data = "=== 搜索结果 ===\n" + "\n".join([f"--- {kw} ---\n{run(f'gitlink-cli search +repos -k \"{kw}\" --format json')}" for kw in kws]) + "\n\n=== Fork关系 ===\n" + "\n".join(relations) + "\n\n=== 深度评估 ===\n" + "\n".join(deep) + report = llm([ + {"role": "system", "content": "你是一个科研热点分析师。基于 GitLink 真实数据生成热点追踪+知识图谱报告。所有仓库链接必须用 gitlink.org.cn 域名,不要用 github.com。"}, + {"role": "user", "content": f"""当前日期: {NOW} +研究主题: {user_input} +关键词: {', '.join(kws)} + +请生成报告(日期写「{NOW}」): +1. 热点概览(项目数、语言、活跃项目比例) +2. 项目排行榜(含成熟度评分,仓库名用 gitlink.org.cn 超链接,不要用 github.com) +3. **领域知识图谱**:用 Mermaid 流程图画出该研究领域的知识结构图,包含核心概念、子方向、关键技术及其关系(不是项目Fork关系,而是学术概念之间的关系) +4. 趋势洞察与建议 + +Mermaid 知识图谱示例格式(使用纯文本,不要用 HTML 标签或 br。**每个连接单独一行,不要用 & 号连接多个节点**): +```mermaid +flowchart LR + A[核心概念] --> B[子方向1] + A --> C[子方向2] + B --> D[技术方法1] + C --> E[技术方法2] +``` + +{data}"""} + ]) + # 清理 AI 在 Mermaid 代码块中混入的 HTML 标签和非法语法 + report_clean = re.sub(r'<[^>]+>', '', report) + report_clean = re.sub(r'(\w+)\s*&', '', report_clean) # 去掉 X & Y --> Z 中的 & + steps.append(("Step4-报告", {"len": len(report_clean)})) + return report_clean, steps + + +def lab_insight(user_input): + """项目洞悉:一个仓库的综合全息分析""" + steps = [] + parts = user_input.replace(" ","").split("/") + if len(parts) < 2: return "请提供 owner/repo 格式", [] + o, r = parts[0], parts[1] + url_s = f"https://www.gitlink.org.cn/{o}/{r}" + + info = run(f'gitlink-cli repo +info --owner {o} --repo {r} --format json') + prs = run(f'gitlink-cli pr +list --owner {o} --repo {r} --format json') + issues = run(f'gitlink-cli issue +list --owner {o} --repo {r} --format json') + steps.append(("Step1-数据采集", {})) + + # 提取贡献者 + authors = set() + try: + for i in json.loads(prs).get("data",{}).get("issues",[]): + if i.get("author_login"): authors.add(i["author_login"]) + except: pass + user_d = [] + for a in list(authors)[:5]: + u = run(f'gitlink-cli user +info --login {a} --format json') + user_d.append(f"# {a}\n{u}") + + data = f"# [{o}/{r}]({url_s})\n{info}\n\n# PR\n{prs}\n\n# Issues\n{issues}\n\n" + "\n".join(user_d) + report = llm([ + {"role": "system", "content": f"你是一个项目洞悉分析师。当前日期{NOW}。综合仓库数据生成全息分析报告。"}, + {"role": "user", "content": f"""仓库: [{o}/{r}]({url_s}) +贡献者: {', '.join(authors) if authors else '无'} + +请综合以下数据生成项目洞悉报告,包含: +1. 项目概况(语言、规模、描述) +2. 社区活跃度(贡献者、PR、Issue) +3. 团队画像(核心贡献者特点) +4. 综合健康度评估与建议 +所有仓库名用超链接 [owner/repo](https://www.gitlink.org.cn/owner/repo)。 + +{data}"""} + ]) + steps.append(("Step2-报告", {"len": len(report)})) + return report, steps + + +def lab_compliance(user_input): + """合规复现检查:License、README、CI、Release""" + steps = [] + parts = user_input.replace(" ","").split("/") + if len(parts) < 2: return "请提供 owner/repo 格式", [] + o, r = parts[0], parts[1] + url_s = f"https://www.gitlink.org.cn/{o}/{r}" + + info = run(f'gitlink-cli repo +info --owner {o} --repo {r} --format json') + builds = run(f'gitlink-cli ci +builds --owner {o} --repo {r} --format json') + steps.append(("Step1-数据采集", {})) + + # 提取关键字段 + has_license, has_readme, has_ci, size, desc, release = "?", "?", "?", "?", "?", 0 + try: + d = json.loads(info).get("data",{}) + has_license = "✅ 有" if d.get("license_id") else "❌ 无" + has_readme = "✅ 有" if d.get("description") else "⚠️ 可能无" + has_ci = "✅ 已激活" if d.get("open_devops") else "❌ 未激活" + size = d.get("size","?") + desc = (d.get("description") or "无描述")[:100] + release = d.get("version_releases_count",0) + except: pass + + data = f"# [{o}/{r}]({url_s})\nLicense: {has_license}\nCI: {has_ci}\nSize: {size}\nRelease: {release}\nDesc: {desc}\n{info}\n\n# CI Builds\n{builds}" + report = llm([ + {"role": "system", "content": f"你是一个开源合规分析师。当前日期{NOW}。检查仓库的合规性和可复现性。"}, + {"role": "user", "content": f"""仓库: [{o}/{r}]({url_s}) + +检查结果: +- License: {has_license} +- CI激活: {has_ci} +- Release: {release} 个 +- 描述: {desc} + +请在此基础上生成合规检查报告: +1. License 合规性(是否有许可证、是否开源友好) +2. 文档完整性(README、描述) +3. CI/CD 完善度(是否激活、构建历史) +4. 可复现性(Release、依赖管理) +5. 综合评分(满分 20)和改进建议 + +{data}"""} + ]) + steps.append(("Step2-报告", {"len": len(report)})) + return report, steps + + +def lab_match(user_input): + """协作匹配:找入门 Issue + 评估社区友好度""" + steps = [] + parts = user_input.replace(" ","").split("/") + if len(parts) < 2: return "请提供 owner/repo 格式", [] + o, r = parts[0], parts[1] + url_s = f"https://www.gitlink.org.cn/{o}/{r}" + + info = run(f'gitlink-cli repo +info --owner {o} --repo {r} --format json') + prs = run(f'gitlink-cli pr +list --owner {o} --repo {r} --format json') + issues = run(f'gitlink-cli issue +list --owner {o} --repo {r} --state open --format json') + steps.append(("Step1-数据采集", {})) + + data = f"# [{o}/{r}]({url_s})\n{info}\n\n# PR\n{prs}\n\n# Open Issues\n{issues}" + report = llm([ + {"role": "system", "content": f"你是一个开源协作匹配专家。当前日期{NOW}。评估项目对新贡献者的友好度。"}, + {"role": "user", "content": f"""仓库: [{o}/{r}]({url_s}) + +请生成协作匹配报告: +1. 项目概览(技术栈、社区规模) +2. 入门友好度分析: + - 是否有 good-first-issue 标签 + - Issue 描述是否清晰 + - PR Review 是否及时 +3. 推荐适合贡献的方向(具体 Issue 或模块) +4. 社区健康度(贡献者多样性、响应速度) +仓库名用 gitlink.org.cn 超链接,不要用 github.com。 + +{data}"""} + ]) + steps.append(("Step2-报告", {"len": len(report)})) + return report, steps + + +def lab_track(user_input): + """进度跟踪与预警:监控多个仓库的活动状态""" + steps = [] + repos = [r.strip().replace(" ","") for r in user_input.split(",") if r.strip()] + if not repos: return "请提供仓库列表,用逗号分隔", [] + + items = [] + warns = {"stale": [], "no_ci": [], "inactive": []} + for repo in repos: + if "/" not in repo: continue + o, rn = repo.split("/")[:2] + info = run(f'gitlink-cli repo +info --owner {o} --repo {rn} --format json') + try: + d = json.loads(info).get("data",{}) + contrib = d.get("contributor_users_count",0) + ci = d.get("open_devops",False) + updated = d.get("full_name","?") + items.append({"repo": repo, "contrib": contrib, "ci": ci, "data": d}) + if not ci: warns["no_ci"].append(repo) + if contrib == 0: warns["inactive"].append(repo) + except: pass + + steps.append(("Step1-巡检", {"count": len(items), "warns": {k: len(v) for k,v in warns.items()}})) + + def repo_info_line(repo): + o, rn = repo.split("/")[:2] + return f"# {repo}\n{run(f'gitlink-cli repo +info --owner {o} --repo {rn} --format json')}" + data_lines = [repo_info_line(r) for r in repos if "/" in r] + data = "\n\n".join(data_lines) + warn_info = "\n".join([f"- ⚠️ {r}: {'CI未激活' if r in warns['no_ci'] else ''} {'无活跃贡献者' if r in warns['inactive'] else ''}" for r in repos if r in warns['no_ci'] or r in warns['inactive']]) or "无预警" + report = llm([ + {"role": "system", "content": f"你是一个开源项目进度跟踪分析师。当前日期{NOW}。生成多仓库进度报告和预警。"}, + {"role": "user", "content": f"""监控仓库: {', '.join(repos)} +预警信息: {warn_info} + +请生成进度跟踪报告: +1. 各仓库状态概览(健康/警告/危险) +2. 详细状态表(贡献者、CI、活跃度) +3. 预警详情(哪些仓库需要关注) +4. 整体健康度评估 + +{data}"""} + ]) + steps.append(("Step3-报告", {"len": len(report)})) + return report, steps + + +LAB_AGENTS = { + "lab-hotspot": lab_hotspot, + "lab-insight": lab_insight, + "lab-compliance": lab_compliance, + "lab-match": lab_match, + "lab-track": lab_track, +} + +AGENTS = { + "research-tracker": agent_research, + "contributor-insight": agent_contributor, + "issue-triage": agent_issue, + "ci-health": agent_ci, + "repo-health": agent_repo_health, + "pr-analytics": agent_pr_analytics, + "cross-search": agent_cross_search, + "user-analysis": agent_user_analysis, + "repo-compare": agent_repo_compare, + **LAB_AGENTS, +} + + +# ── Routes ── + +@app.route("/") +def index(): + return render_template("index.html", skills=SKILL_INFO, url_map={v: k for k, v in SKILL_NAMES.items()}) + + +@app.route("/") +def page(slug): + name = SKILL_NAMES.get(slug, slug) + info = SKILL_INFO.get(name) + if not info: + return "Skill not found", 404 + return render_template("skill.html", skill_name=name, info=info) + + +@app.route("/api/run", methods=["POST"]) +def api_run(): + name = request.form.get("skill", "") + user_input = request.form.get("input", "").strip() + if not name or not user_input: + return jsonify({"error": "缺少参数"}), 400 + + agent = AGENTS.get(name) + if not agent: + return jsonify({"error": f"未知 skill: {name}"}), 400 + + try: + report, steps = agent(user_input) + except Exception as e: + return jsonify({"error": str(e), "traceback": traceback.format_exc()}), 500 + + timestamp = time.strftime("%Y%m%d_%H%M%S") + (HISTORY_DIR / f"{name}_{timestamp}.md").write_text( + f"# {SKILL_INFO[name]['title']} 报告\n\n## 输入\n{user_input}\n\n## 结果\n\n{report}\n\n---\n*生成: {time.strftime('%Y-%m-%d %H:%M:%S')}*", + encoding="utf-8" + ) + return jsonify({"report": report, "steps": steps}) + + +if __name__ == "__main__": + import argparse + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=int(os.environ.get("PORT", 5000))) + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--debug", action="store_true", default=False) + args = p.parse_args() + print(f"GitLink Skills Web Service: http://{args.host}:{args.port}") + app.run(host=args.host, port=args.port, debug=args.debug) diff --git a/gitlink-web/deploy.py b/gitlink-web/deploy.py new file mode 100644 index 0000000..c0ea070 --- /dev/null +++ b/gitlink-web/deploy.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Deploy GitLink Skills Web Service to Alibaba Cloud.""" +import os +import sys +import subprocess + +HOST = "121.41.212.97" +PORT = 22 +USER = "root" +PASSWORD = "" +API_KEY = "" + +LOCAL_DIR = os.path.dirname(os.path.abspath(__file__)) +REMOTE_DIR = "/opt/gitlink-web" + +try: + import paramiko +except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "paramiko", "-q"]) + import paramiko + +def run_ssh(ssh, cmd): + _, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode().strip() + err = stderr.read().decode().strip() + return out, err, exit_code + +def main(): + print(f"[1/8] Connecting to {HOST}...") + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, PORT, USER, PASSWORD, timeout=10) + print(" OK - Connected") + + print("[2/8] Installing system dependencies...") + run_ssh(ssh, "apt-get update -qq && apt-get install -y -qq python3-pip npm nodejs 2>&1 | tail -3") + + print("[3/8] Installing gitlink-cli...") + run_ssh(ssh, "npm install -g gitlink-cli 2>&1 | tail -3") + v, _, _ = run_ssh(ssh, "gitlink-cli version 2>&1 || echo 'not found'") + print(f" gitlink-cli: {v}") + + print(f"[4/8] Creating {REMOTE_DIR}...") + run_ssh(ssh, f"mkdir -p {REMOTE_DIR}/templates {REMOTE_DIR}/skills {REMOTE_DIR}/reports") + + print("[5/8] Uploading files...") + sftp = ssh.open_sftp() + for root, dirs, files in os.walk(LOCAL_DIR): + for fname in files: + if fname.endswith(('.py', '.txt', '.html')) or fname in ('requirements.txt', 'README.md'): + local_path = os.path.join(root, fname) + rel_path = os.path.relpath(local_path, LOCAL_DIR) + remote_path = f"{REMOTE_DIR}/{rel_path}" + try: + sftp.put(local_path, remote_path) + print(f" -> {rel_path}") + except Exception as e: + print(f" FAIL {rel_path}: {e}") + sftp.close() + + print("[6/8] Installing Python dependencies...") + run_ssh(ssh, f"cd {REMOTE_DIR} && pip3 install -r requirements.txt -q 2>&1 | tail -3") + + print("[7/8] Creating systemd service...") + service = '\n'.join([ + '[Unit]', + 'Description=GitLink Skills Web Service', + 'After=network.target', + '', + '[Service]', + 'Type=simple', + 'User=root', + f'WorkingDirectory={REMOTE_DIR}', + f'Environment="API_KEY={API_KEY}"', + 'Environment="API_MODEL=deepseek-chat"', + f'ExecStart=/usr/bin/python3 {REMOTE_DIR}/app.py --port=80', + 'Restart=always', + 'RestartSec=5', + '', + '[Install]', + 'WantedBy=multi-user.target', + ]) + escaped_service = service.replace('"', '\\"').replace("'", "\\'") + run_ssh(ssh, f"cat > /etc/systemd/system/gitlink-web.service << 'EOF'\n{service}\nEOF") + + print("[8/8] Starting service...") + run_ssh(ssh, "systemctl daemon-reload") + run_ssh(ssh, "systemctl enable gitlink-web") + _, err, code = run_ssh(ssh, "systemctl start gitlink-web") + if err and "Unit" not in err: + print(f" WARN: {err[:200]}") + out, _, _ = run_ssh(ssh, "systemctl status gitlink-web --no-pager -l | head -15") + print(f"\n {out.replace(chr(10), chr(10)+' ')}") + + print(f"\nService URLs:") + print(f" http://{HOST}/") + print(f" http://{HOST}/research") + print(f" http://{HOST}/contributor") + + ssh.close() + +if __name__ == "__main__": + main() diff --git a/gitlink-web/fork自 b/gitlink-web/fork自 new file mode 100644 index 0000000..e69de29 diff --git a/gitlink-web/requirements.txt b/gitlink-web/requirements.txt new file mode 100644 index 0000000..3c63ab8 --- /dev/null +++ b/gitlink-web/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +requests>=2.31 +gunicorn>=21.2 diff --git a/gitlink-web/skills/ci-health.txt b/gitlink-web/skills/ci-health.txt new file mode 100644 index 0000000..bdd0e14 --- /dev/null +++ b/gitlink-web/skills/ci-health.txt @@ -0,0 +1,179 @@ +# gitlink-ci-health(CI 健康巡检) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作。CI 激活/关闭需通过 GitLink Web 界面操作,CLI 不提供对应命令。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +面向维护者的 CI/CD 健康度巡检工具: + +1. **授权检查** — 确认仓库 CI 是否已激活 +2. **构建历史** — 获取近期构建列表 +3. **成功率统计** — 计算构建成功率和平均耗时 +4. **故障分析** — 识别频繁失败的构建及其原因 +5. **健康报告** — 生成 CI 健康度评分和改进建议 + +--- + +## 工作流:CI 健康巡检 + +### Step 1:检查 CI 授权状态 + +**方法 1(推荐)**:通过 `repo +info` 查看 `open_devops` 字段: + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +- `"open_devops": true` → CI 已激活 +- `"open_devops": false` → CI 未激活 + +**方法 2**:直接调用 `ci +builds`,CI 未激活时返回: + +```json +{"status": -1, "message": "接口数据异常"} +``` + +> ⚠️ `ci +authorize` 命令在当前 CLI 版本(v0.1.18)中**不存在**。可用 CI 命令仅:`+builds`、`+logs`、`+restart`、`+stop`。 + +若 CI 未激活,报告中说明"CI 未启用",建议通过 GitLink Web 界面(仓库设置 → DevOps)开启,随后不再继续后续步骤。 + +### Step 2:获取构建历史 + +```bash +gitlink-cli ci +builds --owner --repo --format json +``` + +提取每次构建的: +- `status` — 构建状态(success/failed/running/pending) +- `created_at` / `finished_at` — 时间信息 +- `duration` — 耗时(如有) +- `branch` — 触发分支 + +如构建数量 >30,取最近 30 次分析。 + +### Step 3:构建日志(失败构建) + +对状态为 failed 的构建获取日志: + +```bash +gitlink-cli ci +logs --owner --repo --build --format json +``` + +> ⚠️ **控制调用量**:仅对最近 5 次失败构建获取日志,避免过多 API 调用。日志可能过大,提取关键错误行(最后 20 行)。 + +### Step 4:统计分析 + +#### 4.1 成功率计算 + +| 指标 | 计算方式 | +|------|----------| +| 整体成功率 | 成功构建数 / 总构建数 × 100% | +| 近 10 次成功率 | 最近 10 次中成功占比 | +| 平均修复时间 | 从失败到下次成功的平均间隔 | + +#### 4.2 健康度评分(满分 20) + +| 维度 | 权重 | 评分标准 | +|------|------|----------| +| CI 激活 | 4 | 已激活=4,未激活=0 | +| 构建成功率 | 5 | ≥90%=5,≥80%=4,≥70%=3,≥50%=2,<50%=1 | +| 近期稳定性 | 5 | 近10次全部成功=5,8-9次=4,6-7次=3,4-5次=2,<4次=1 | +| 构建频率 | 3 | 每天有构建=3,2-3天=2,每周=1,更少=0 | +| 修复速度 | 3 | 失败后1次内修复=3,2-3次=2,>3次=1 | + +### Step 5:生成 CI 健康报告 + +--- + +## 输出模板 + +```markdown +# 🔧 CI 健康巡检报告:{{仓库名}} + +> 巡检时间:{{当前时间}} +> 仓库:{{full_name}} +> CI 状态:{{ci_status_display}} + +--- + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | {{activated_status}} | {{activate_score}}/4 | +| 整体成功率 | {{success_rate}}%({{success_count}}/{{total_count}}) | {{success_score}}/5 | +| 近期稳定性 | 近 10 次 {{recent_success}} 次成功 | {{stability_score}}/5 | +| 构建频率 | {{build_frequency_desc}} | {{frequency_score}}/3 | +| 修复速度 | {{repair_speed_desc}} | {{repair_score}}/3 | +| **总分** | | **{{total_score}}/20** | + +## 二、构建趋势 + +``` +最近 20 次构建: +✅✅❌✅✅✅❌✅✅✅✅✅❌✅✅✅✅✅✅ +(✅=成功 ❌=失败) +``` + +| 时间段 | 总构建 | 成功 | 失败 | 成功率 | +|--------|--------|------|------|--------| +| 最近 7 天 | {{w1_total}} | {{w1_success}} | {{w1_fail}} | {{w1_rate}}% | +| 7-14 天 | {{w2_total}} | {{w2_success}} | {{w2_fail}} | {{w2_rate}}% | +| 14-30 天 | {{w3_total}} | {{w3_success}} | {{w3_fail}} | {{w3_rate}}% | + +## 三、故障分析 + +> 如无失败构建,输出:**🎉 分析期内无失败构建,CI 运行健康。** + +| 构建 ID | 分支 | 失败时间 | 错误摘要 | +|---------|------|----------|----------| +| {{id}} | {{branch}} | {{time}} | {{error_summary}} | + +### 故障模式分类 + +| 故障类型 | 次数 | 占比 | +|----------|------|------| +| 编译错误 | {{compile_count}} | {{compile_pct}}% | +| 测试失败 | {{test_fail_count}} | {{test_fail_pct}}% | +| 超时 | {{timeout_count}} | {{timeout_pct}}% | +| 环境问题 | {{env_count}} | {{env_pct}}% | +| 其他 | {{other_count}} | {{other_pct}}% | + +## 四、改进建议 + + + +- **立即激活 CI**(当 CI 未激活时):前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务(CLI 暂不支持 `ci +activate`) +- **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因 +- **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI +- **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| CI 未激活 | 报告 CI 状态为"未激活",建议通过 Web 界面开启,不再继续后续步骤 | +| 无构建记录 | 标注"仓库暂无 CI 构建记录" | +| `ci +logs` 返回空 | 标注"日志不可用" | +| 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **CI 激活/关闭需通过 GitLink Web 界面**,CLI 不提供 `+activate`/`+deactivate` 命令 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行 +- ⚠️ **构建历史无分页参数**,实际返回条数取决于 API +- ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务 +- ⚠️ **`repo +info` 的 `open_devops` 字段**是判断 CI 是否激活的最可靠方式 \ No newline at end of file diff --git a/gitlink-web/skills/contributor-insight.txt b/gitlink-web/skills/contributor-insight.txt new file mode 100644 index 0000000..27c883d --- /dev/null +++ b/gitlink-web/skills/contributor-insight.txt @@ -0,0 +1,247 @@ +# gitlink-contributor-insight(贡献者活跃度分析) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何仓库。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## ⚠️ 命令可用性声明 + +gitlink-cli 的命令集在持续演进中。以下命令**当前版本可能不可用**,执行前先验证: + +| 命令 | 状态 | 替代方案 | +|------|------|----------| +| `repo +contributors` | ❌ 不可用 | 从 `pr +list` 提取 `author_login` + `repo +info` 获取 `contributor_users_count` | +| `user +heatmap` | ❌ 不可用 | 从 PR 时间戳手动推算活跃天数 | +| `user +stats` | ❌ 不可用 | 从 `pr +list` 统计 PR 数;Issue 数通过 `issue +list` 获取 | +| `user +trends` | ❌ 不可用 | 从 PR 时间分布手动判断趋势(上升/平稳/下降) | +| `repo +info` | ✅ 可用 | — | +| `pr +list` | ✅ 可用 | — | +| `user +info` | ✅ 可用 | — | +| `issue +list` | ✅ 可用 | — | + +> **核心原则**:优先使用可用的 Shortcut 命令。当所需命令不可用时,从 `pr +list` 和 `user +info` 中提取等效数据,并在报告中标注"数据来源:PR 列表(命令 X 不可用)"。 + +--- + +## 功能概述 + +面向开源社区管理者和维护者的贡献者分析工具: + +1. **项目概览** — 获取仓库贡献者规模和基本信息 +2. **贡献数据分析** — 通过 `pr +list` 提取每位贡献者的 PR 数量和时间分布 +3. **用户画像** — 通过 `user +info` 了解贡献者背景 +4. **趋势判断** — 从 PR 时间序列推断贡献趋势 +5. **洞察报告** — 生成贡献者活跃度排名和团队健康度评估 + +--- + +## 工作流:贡献者分析全流程 + +### Step 1:获取仓库基本信息 + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +提取:`contributor_users_count`、`full_name`、`description`、`default_branch`、`fork_info`(如为 Fork 项目)。 + +### Step 2:获取贡献者列表(通过 PR 数据) + +由于 `repo +contributors` 不可用,改用两步获取贡献者: + +```bash +# 2a. 获取所有 PR(含已合并和已关闭) +gitlink-cli pr +list --owner --repo --format json + +# 2b. 如果 Issue 数据也需要 +gitlink-cli issue +list --owner --repo --format json +``` + +从 `pr +list` 返回数据中: +- 提取所有唯一的 `author_login` 作为实际代码贡献者 +- 统计每位作者的 PR 数(`pull_request_status`: 0=open, 1=merged, 2=closed) +- 记录每个 PR 的 `pr_full_time` 用于时间分析 +- 记录每个 PR 的 `journals_count`(评论/审核活动数) + +从 `issue +list` 返回数据中: +- 提取所有唯一的 `author_login` 作为 Issue 参与者 +- 统计每位作者的 Issue 数 + +> 如果 PR 数量较多(>50),按 `author_login` 聚合后取 PR 数前 10 的贡献者分析,报告中注明"基于 Top 10 分析"。 + +### Step 3:逐位贡献者深度分析 + +对每位贡献者执行: + +```bash +# 用户基本信息 +gitlink-cli user +info --login --format json +``` + +从 `user +info` 提取:`login`、`name`、`created_time`(注册时间)、`user_projects_count`、`user_org_count`、`user_identity`。 + +**如果 `user +heatmap/+stats/+trends` 可用**(未来版本),补充执行。当前版本用以下替代方案: + +| 维度 | 替代数据源 | 分析要点 | +|------|----------|----------| +| 贡献频率 | PR 时间戳列表 | 统计活跃天数、相邻 PR 间隔、判断"持续贡献者"还是"间歇参与者" | +| 贡献产出 | `pr +list` 聚合 | PR 数、Issue 数,区分"代码贡献者"和"问题反馈者" | +| 活跃趋势 | PR 按日/周聚合 | 贡献量上升/稳定/下降,识别"上升期贡献者"和"逐渐淡出者" | + +> ⚠️ **控制 API 调用**:贡献者 >15 人时,仅分析 PR 数最高的前 10 位。每人 1 次 `user +info` 调用(共 ≤10 次),PR 数据已在 Step 2 全量获取。 + +### Step 4:贡献者分级与分类 + +#### 4.1 活跃度分级 + +| 级别 | 判定标准 | +|------|----------| +| 🔥 **核心贡献者** | 最近 30 天有贡献 + 总贡献 PR ≥ 5(或总贡献 PR > 10) | +| 🌟 **活跃贡献者** | 最近 60 天有贡献 + 总贡献 ≥ 3 | +| 🌱 **新兴贡献者** | 最近 90 天首次出现 + 贡献频率上升 | +| 💤 **休眠贡献者** | 最近 90 天无贡献 + 历史有贡献 | + +> **年轻项目特殊处理**:项目历史 < 30 天时,放宽标准——所有活跃贡献者均可标记为核心贡献者,报告中注明"项目处于早期阶段,分级标准已放宽"。 + +#### 4.2 贡献类型分类 + +| 类型 | 判定 | +|------|------| +| **代码贡献者** | PR 数量 > Issue 数量 | +| **问题反馈者** | Issue 数量 > PR 数量 | +| **全能贡献者** | PR 和 Issue 数量均衡(差异 ≤ 1) | + +### Step 5:生成贡献者洞察报告 + +按下方输出模板生成报告,并根据数据可用性灵活调整章节。 + +--- + +## 输出模板 + +```markdown +# 👥 贡献者洞察报告:{{仓库名}} + +> 分析时间:{{当前时间}} +> 仓库:{{full_name}} +> 总贡献者:{{contributor_users_count}} 人,本次分析:{{analyzed_count}} 人 + +--- + +## 一、团队概览 + +| 指标 | 数值 | +|------|------| +| 总贡献者 | {{contributor_users_count}} | +| 核心贡献者 | {{core_count}} | +| 活跃贡献者 | {{active_count}} | +| 新兴贡献者 | {{new_count}} | +| 休眠贡献者 | {{dormant_count}} | +| 近 30 天活跃率 | {{active_30d_rate}}% | + +--- + +## 二、贡献者活跃度排行榜 + +| 排名 | 贡献者 | 级别 | 类型 | 活跃天数 | 总PR | 总Issue | 趋势 | +|------|--------|------|------|---------|------|---------|------| +| 1 | {{login}} | 🔥 | 代码 | {{d}} 天 | {{pr_count}} | {{issue_count}} | ↑ | +| ... | ... | ... | ... | ... | ... | ... | ... | + +--- + +## 三、重点贡献者分析 + +> 仅展示核心/活跃贡献者。 + +### 🔥 {{login}}(核心贡献者) + +| 维度 | 数据 | 说明 | +|------|------|------| +| 活跃天数 | {{d}} 天 | {{评价}} | +| 总 PR 数 | {{pr_count}} | | +| 总 Issue 数 | {{issue_count}} | | +| 贡献趋势 | {{trend_direction}} | {{trend_comment}} | + +**PR 贡献明细**:(可选,数据充足时展示) + +| PR# | 标题 | 日期 | 类型 | +|-----|------|------|------| +| ... | ... | ... | feat/fix/refactor | + +--- + +## 四、团队健康度评估 + +### 健康度指标 + +| 指标 | 状态 | 说明 | +|------|------|------| +| 核心贡献者占比 | {{core_ratio}}% | {{core_comment}} | +| 新老比例 | {{new_old_ratio}} | {{new_old_comment}} | +| 贡献频率稳定性 | {{stability}} | {{stability_comment}} | +| 知识分散度 | {{bus_factor}} | {{bus_factor_comment}} | + +### 风险提示 + +- ⚠️ **核心贡献者不足**(当 core_count < 3 时):仅 {{core_count}} 位核心贡献者,存在单点依赖风险(Bus Factor = {{core_count}})。 +- ⚠️ **贡献者流失**(当 dormant_rate > 50% 时):超过一半的贡献者已不活跃,需要关注社区留存。 +- ⚠️ **缺少新鲜血液**(当 new_count == 0 时):近期无新兴贡献者,建议通过 Good First Issue 等方式吸引新人。 +- ℹ️ **项目处于早期阶段**(当项目历史 < 30 天时):贡献者分级标准已放宽,以上风险置信度有限。 +- ✅ **团队健康**(当以上情况均不满足时):贡献者结构合理,团队运转良好。 + +--- + +## 五、社区建设建议 + +1. **激励核心贡献者**:{{核心贡献者维护建议}} +2. **激活休眠贡献者**:{{休眠贡献者召回建议}} +3. **吸引新贡献者**:{{新贡献者吸引建议}} +4. **平衡贡献类型**:{{贡献类型平衡建议}} + +--- + +## 📋 数据来源与局限性 + +| 数据维度 | 来源 | 可靠性 | +|----------|------|--------| +| 贡献者数量 | `repo +info` | ✅ 可靠 | +| PR 贡献数据 | `pr +list` 全量 | ✅ 可靠 | +| Issue 数据 | `issue +list` | ✅ 可靠 | +| 用户信息 | `user +info` | ✅ 可靠 | +| 贡献热力图 | 不可用(命令未实现) | ❌ 缺失 | +| 统计信息 | 不可用(命令未实现) | ❌ 缺失 | +| 趋势数据 | 不可用(命令未实现) | ❌ 缺失 | + +> **局限性**:本报告仅反映 GitLink 平台活动,不包括其他平台(GitHub、GitLab 等)的数据。 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| `repo +contributors` 不可用(当前版本常态) | 从 `pr +list` 的 `author_login` 提取贡献者列表 | +| `user +heatmap` / `+stats` / `+trends` 不可用 | 从 PR 时间戳推算活跃天数,PR 聚合得产出量,时间分布得趋势 | +| `pr +list` 返回空 | 标注"仓库暂无 PR 数据",仅展示 `repo +info` 基本信息 | +| `user +info` 返回空 | 标注"用户信息不可用",仅展示 PR 统计 | +| 贡献者 > 15 人 | 仅分析 PR 数最高的前 10 位,报告中注明"基于 Top 10 分析" | +| 项目历史 < 30 天 | 放宽分级标准,报告中注明"项目处于早期阶段" | +| `issue +list` 返回空 | Issue 数列为 0,贡献类型统一标注"代码贡献者" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 +- ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 +- ⚠️ **核心数据来源为 `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) 包含原始命令输出数据 \ No newline at end of file diff --git a/gitlink-web/skills/issue-triage.txt b/gitlink-web/skills/issue-triage.txt new file mode 100644 index 0000000..3ff8354 --- /dev/null +++ b/gitlink-web/skills/issue-triage.txt @@ -0,0 +1,216 @@ +# gitlink-issue-triage(Issue 智能分拣) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何 Issue。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +对仓库的开放 Issue 进行全量扫描和智能分类,输出结构化的分拣报告: + +1. **类型分类** — 判断每个 Issue 是 Bug、功能请求、文档问题还是使用咨询 +2. **紧急度评估** — 根据关键词和优先级字段标注紧急程度 +3. **复杂度预估** — 根据描述详尽程度评估修复难度 +4. **行动建议** — 给出具体处理建议(立即修复/需讨论/可关闭/适合作入门任务) + +--- + +## 工作流:Issue 全量分拣 + +### Step 1:获取项目概览 + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +提取 `issues_count` 了解 Issue 池总量,`default_branch` 确认主分支。 + +### Step 2:获取全部开放 Issue + +```bash +gitlink-cli issue +list --owner --repo --state open --format json +``` + +> ⚠️ **已知问题**:`--state open` 过滤不准确,返回列表可能包含已关闭的 Issue。需在客户端按 `status_id` 二次过滤:保留 `status_id` = 1(新增)或 2(正在解决),排除 3(已解决)、5(关闭)。`status_id` = 0 纳入分析但标注"状态异常"。 + +如果返回数量 >20,追加分页参数获取全部: + +```bash +gitlink-cli issue +list --owner --repo --state open --format json --page 2 +``` + +### Step 3:逐条深入分析 + +对过滤后的每条 Issue,获取详情: + +```bash +gitlink-cli issue +view --owner --repo --number --format json +``` + +分析以下维度: + +| 维度 | 关注字段 | 分析要点 | +|------|----------|----------| +| 类型 | `subject`, `description` | 标题和描述中的关键词 | +| 紧急度 | `priority`, `subject` | 优先级字段 + 标题紧急信号 | +| 复杂度 | `description` 长度 | 描述的详细程度、是否有复现步骤 | +| 活跃度 | `comment_journals_count`, `updated_at` | 讨论热度和最后活跃时间 | +| 分配状态 | `assigners` | 是否已有人负责 | + +### 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:生成分拣报告 + +将所有分析结果组织输出。 + +--- + +## 输出模板 + +```markdown +# 📊 {{仓库名}} Issue 分拣报告 + +> 分析时间:{{当前时间}} +> Issue 总数:{{total}},开放:{{open_count}},本次分析:{{analyzed_count}} 条 + +--- + +## 总览 + +| 指标 | 数量 | +|------|------| +| 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 | fix-now | | +| ... | ... | ... | ... | ... | ... | ... | + +## 🟡 建议近期处理 + +> 如本段为空,输出:*当前无高优先级 Issue。* + +| # | 标题 | 类型 | 紧急度 | 复杂度 | 建议 | 备注 | +|---|------|------|--------|--------|------|------| +| ... | ... | bug/feature | high/normal | easy/medium | investigate/implement | | + +## 🟢 可延迟 / 需讨论 + +> 如本段为空,输出:*所有 Issue 均已明确,无需额外讨论。* + +| # | 标题 | 类型 | 紧急度 | 复杂度 | 建议 | 备注 | +|---|------|------|--------|--------|------|------| +| ... | ... | question/feature | normal/low | medium/hard | discuss | | + +## ⭐ 适合入门(Good First Issue) + +> 如本段为空,输出:*暂无完全符合条件的入门 Issue。建议在后续工作中拆分出简单子任务。* + +| # | 标题 | 类型 | 复杂度 | 推荐理由 | +|---|------|------|--------|----------| +| {{number}} | {{subject}} | bug/docs | easy | 范围明确,单文件修改 | +| ... | ... | ... | ... | ... | + +## ⚠️ 候选关闭(90+ 天无活动) + +> 如本段为空,输出:*无长期不活跃的 Issue。* + +| # | 标题 | 最后更新 | 建议 | +|---|------|----------|------| +| {{number}} | {{subject}} | {{updated_at}} | 评论询问是否仍需要,如无回应可关闭 | + +--- + +## 📋 维护建议 + +1. **立即行动**:{{urgent_count}} 个紧急 Issue 需要优先处理 +2. **本周目标**:建议处理 {{suggested_this_week}} 个 Issue(suggested_this_week = 建议近期处理段中的 Issue 数量,即 bug+normal/high + feature+清晰描述 的总数) +3. **社区引导**:{{good_first_issue_count}} 个 Issue 适合标记为 good first issue,吸引新贡献者 +4. **清理计划**:{{close_candidate_count}} 个 Issue 长期无活动,建议批量确认后关闭 +5. {{#if no_tags}}本仓库未使用 Issue 标签系统,建议建立标签体系(bug/feature/docs/question/meta/help-wanted/good-first-issue)以提升管理效率{{/if}} +6. {{#if status_anomalies}}本批次有 {{status_anomaly_count}} 个 Issue 状态异常(status_id=0),建议在平台上手动确认{{/if}} +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| 无开放 Issue | 输出 `repo +info` 概览后,恭喜维护者"Issue 池已清空" | +| Issue 数量 >50 | 优先分析最近 30 天更新的 Issue,其余标记为"待分批处理" | +| 全部 Issue 无标签/无优先级 | 分类完全依赖标题和描述关键词分析,并在报告末尾建议建立标签体系 | +| `description` 为空或仅含图片/附件链接 | 标注"描述缺失",类型仅根据标题判断,复杂度标为 hard,建议标记为 discuss | +| `status_id` = 0(未知) | 纳入分析但标注"状态异常" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **`issue +view` 使用 `--number`(网页编号)**,非数据库 ID +- ✅ **本 Skill 为纯只读分析**,不会修改任何 Issue +- ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 +- ⚠️ **`issue +list --state open` 过滤不准确**,必须客户端按 `status_id` 二次过滤 +- ⚠️ **分类规则是启发式的**,AI 应根据实际内容做判断,不要机械匹配关键词 +- ⚠️ **Issue 数量多时分批处理**,超过 50 条建议先按更新时间排序,优先分析最近活跃的 \ No newline at end of file diff --git a/gitlink-web/skills/research-tracker.txt b/gitlink-web/skills/research-tracker.txt new file mode 100644 index 0000000..2c30a55 --- /dev/null +++ b/gitlink-web/skills/research-tracker.txt @@ -0,0 +1,246 @@ +# gitlink-research-tracker(科研热点追踪) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何仓库。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +面向科研场景的技术调研工具,帮助研究者快速了解 GitLink 平台上的技术格局: + +1. **多关键词搜索** — 将研究主题拆解为多个关键词,全面覆盖相关项目 +2. **项目深度评估** — 从活跃度、社区规模、代码产出等维度评估项目健康度 +3. **横向对比** — 对比同类项目的核心指标,识别领先者和潜力项目 +4. **趋势洞察** — 基于更新时间、贡献者增长、版本发布频率等推断技术趋势 +5. **调研报告** — 生成结构化的技术调研简报 + +--- + +## 工作流:技术调研全流程 + +### Step 1:理解研究主题,拆解搜索关键词 + +根据用户的研究主题,拆解 3~5 个搜索关键词: + +| 研究主题示例 | 拆解关键词 | +|-------------|-----------| +| "AI Agent 工具" | `agent`, `AI`, `LLM`, `智能体`, `copilot` | +| "DevOps 流水线" | `devops`, `pipeline`, `CI/CD`, `自动化部署`, `容器` | +| "开源合规" | `license`, `compliance`, `合规`, `sbom`, `供应链安全` | +| "微服务框架" | `microservice`, `微服务`, `rpc`, `服务网格`, `cloud native` | + +> **原则**:关键词应覆盖中英文、缩写全称、技术术语和行业叫法。每个关键词独立搜索。 + +### Step 2:多关键词搜索 + +对每个关键词执行搜索: + +```bash +gitlink-cli search +repos -k <关键词> --format json +``` + +对每个搜索结果,提取: + +| SKILL 中用到的概念 | 实际字段来源 | 说明 | +|-------------------|-------------|------| +| owner/repo 标识 | `author.login` + `/` + `identifier` | 搜索结果**没有** `full_name`,需手动拼接。`identifier` 是仓库的唯一标识符 | +| 项目描述 | `description` | 直接可用 | +| 关注度 | `praises_count` | 搜索结果中叫 `praises_count`,**不是** `stars`。`watchers_count` 仅在 `repo +info` 中返回 | +| 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 镜像,需特别标注 | + +**去重规则**:用 `author.login/identifier` 作为唯一标识。同一仓库出现在多个关键词结果中时,只保留一次,标注匹配了哪些关键词。 + +**数量控制**:每个关键词保留前 8 个结果。合并去重后总数控制在 20 个以内。超出时优先保留匹配多关键词的和 `last_update_time` 最近的。 + +### Step 3:重点项目深度评估 + +对合并去重后的每个项目,获取详细指标: + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +从返回数据中提取核心评估维度: + +| 维度 | 字段 | 评估标准 | +|------|------|----------| +| **社区活跃度** | `contributor_users_count` | >20 大社区,5~20 中等,<5 小团队 | +| **关注度** | `watchers_count`, `praises_count` | 反映项目知名度 | +| **研发节奏** | `version_releases_count` | >20 快速迭代,5~20 正常,<5 慢速 | +| **代码规模** | `size` | 粗略判断项目复杂度 | +| **开放性** | `forked_count` | fork 数反映二次开发热度 | +| **PR 活跃度** | `pull_requests_count` | 反映代码贡献频率 | + +可选补充(如有需要): + +```bash +# 查看最近 Issue 活跃度 +gitlink-cli issue +list --owner --repo --state open --format json + +# 查看最近 Release 情况 +gitlink-cli release +list --owner --repo --format json +``` + +> ⚠️ **控制分析数量**:深度评估仅对最有价值的 5~8 个项目执行(优先匹配多关键词、watchers 多、updated_at 最近的项目),避免过多 API 调用。 + +### Step 4:横向对比与趋势分析 + +#### 4.1 项目分类与镜像识别 + +在评分之前,先通过 `repo +info` 的 `mirror` 字段区分项目类型: + +| 类型 | 判定 | 处理 | +|------|------|------| +| **镜像仓库** | `mirror: true` | 标注 `[镜像]`。GitLink 上的 `contributor_users_count`/`watchers_count` 等指标均为 0,不代表真实社区活跃度。评分仅作参考 | +| **原创仓库** | `mirror: false` 且 `forked_from_project_id: null` | 正常评分 | +| **Fork 仓库** | `forked_from_project_id` 非 null | 标注 `[Fork]`,评分反映的是 Fork 后的独立开发情况 | + +#### 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 | forked_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | + +> **镜像修正**:镜像仓库的社区规模、关注度、开放性三项在 GitLink 上均为 0,应标注"数据为 GitLink 平台内数据,不代表项目在原始平台(GitHub)的真实影响力",不参与排名比较。 + +#### 4.3 技术趋势推断 + +- **增长信号**:近期频繁 Release + contributor 增长 + fork 增长 → 技术热点上升期 +- **成熟信号**:大量 watcher + 稳定 Release 节奏 + 大社区 → 技术趋于成熟 +- **衰退信号**:超过 180 天无更新 + 少量 contributor + 无新 Release → 可能已不活跃 +- **新兴信号**:小社区 + 快速迭代 + 最新更新时间近 → 可能是新兴项目 + +### Step 5:生成技术调研报告 + +--- + +## 输出模板 + +```markdown +# 🔬 技术调研报告:{{研究主题}} + +> 调研时间:{{当前时间}} +> 搜索关键词:{{keyword_list}} +> 搜索命中:{{total_hits}} 个仓库,去重后 {{unique_count}} 个,深度分析 {{deep_analysis_count}} 个 + +--- + +## 一、技术格局概览 + +| 指标 | 数值 | +|------|------| +| 相关项目总数 | {{unique_count}} | +| 主要编程语言 | {{top_languages}} | +| 平均社区规模 | {{avg_contributors}} 人 | +| 近 30 天活跃项目 | {{active_30d_count}}({{active_30d_pct}}%) | +| 高成熟度项目(≥20分) | {{high_maturity_count}} | + +--- + +## 二、项目成熟度排行榜 + +| 排名 | 项目 | 类型 | 评分 | 语言 | Watch | 贡献者 | Release | Fork | 关键词匹配 | +|------|------|------|------|------|-------|--------|---------|------|------------| +| 1 | {{full_name}} {{#if mirror}}[镜像]{{/if}} | {{原创/镜像/Fork}} | {{score}}/25 | {{language}} | {{watchers}} | {{contributors}} | {{releases}} | {{forks}} | {{matched_keywords}} | +| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | + +--- + +## 三、重点项 + +> 仅展示评分 ≥15 或匹配 3+ 关键词的高潜力项目。 + +### 🥇 {{项目名}}({{score}}/25) + +| 维度 | 评分 | 说明 | +|------|------|------| +| 社区规模 | {{community_score}}/5 | {{contributor_users_count}} 位贡献者 | +| 关注度 | {{popularity_score}}/5 | {{watchers_count}} watch, {{praises_count}} star | +| 研发节奏 | {{release_score}}/5 | {{version_releases_count}} 个版本发布 | +| 开发活跃 | {{activity_score}}/5 | 最后更新于 {{last_update}} | +| 开放性 | {{openness_score}}/5 | {{forked_count}} 次 fork | + +**亮点**:{{一句话总结项目最大优势}} +**关注点**:{{一句话指出需要关注的风险或不足}} + +--- + +### 🥈 {{项目名}}({{score}}/25) + +(同上格式) + +--- + +## 四、技术趋势洞察 + +1. **热点方向**:{{当前最热的技术方向,基于项目分布推断}} +2. **新兴项目**:{{列出 1~3 个"新兴信号"明显的项目}} +3. **成熟生态**:{{列出 1~2 个"成熟信号"明显的项目,适合作为技术选型参考}} +4. **风险提示**:{{列出 1~2 个"衰退信号"项目或值得关注的生态空白}} + +--- + +## 五、调研建议 + +### 技术选型推荐 + +| 场景 | 推荐项目 | 理由 | +|------|----------|------| +| 生产环境使用 | {{最成熟的项目}} | 社区大、更新稳定、文档完善 | +| 学习入门 | {{最简单的项目}} | 代码量小、贡献门槛低 | +| 前沿探索 | {{最新兴的项目}} | 技术新颖、迭代快速 | + +### 研究选题建议 + +- {{基于当前生态,建议 2~3 个可深入研究的选题}} +- {{指出 1~2 个生态空白,可能是创新机会}} + +--- + +## 六、数据来源 + +所有数据通过 `gitlink-cli` 从 GitLink 平台实时获取,每个项目均已通过 `repo +info` 验证。 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| 关键词无搜索结果 | 尝试近义词或更宽泛的关键词重试,仍无结果则标注"该方向暂无相关项目" | +| 搜索返回大量结果(>50) | `search +repos` 无分页参数,实际返回约 20 条/关键词。合并后按 `praises_count` 降序取前 20 | +| 某项目 `repo +info` 返回 404 | 该项目可能为私有或已删除,从列表中移除 | +| `repo +info` 网络超时/TLS 错误 | 等待 5 秒后重试一次。仍失败则标注"网络请求失败",跳过该项目继续分析其余 | +| 大量搜索结果来自镜像仓库 | 优先分析 `mirror: false` 的原创项目。镜像项目保留但标注,评分仅作参考 | +| 所有项目评分均 <15 | 说明该领域尚未形成成熟生态,调整报告语气为"早期探索阶段" | +| 用户未提供具体关键词 | 引导用户明确研究主题,提供几个示例关键词供选择 | +| `language` 字段为 `null` | 标注为"未知" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--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 以国内开发者和企业项目为主,搜索结果可能偏向中文技术生态,且镜像项目较多 \ No newline at end of file diff --git a/gitlink-web/templates/base.html b/gitlink-web/templates/base.html new file mode 100644 index 0000000..4067d29 --- /dev/null +++ b/gitlink-web/templates/base.html @@ -0,0 +1,25 @@ + + + + + + GitLink Skills + + + + + +
+ {% block content %}{% endblock %} +
+ +
+ GitLink Skills Web Service — 基于 Flask + DeepSeek API + gitlink-cli +
+ + diff --git a/gitlink-web/templates/index.html b/gitlink-web/templates/index.html new file mode 100644 index 0000000..e0df8a9 --- /dev/null +++ b/gitlink-web/templates/index.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block content %} + +
+

GitLink Skills 智能分析服务

+

+ 基于 DeepSeek API 和 gitlink-cli,通过 AI Agent 自动搜索、分析 GitLink 平台数据 +

+
+ + +

🔧 工具

+
+ {% for key, skill in skills.items() %} + {% if not key.startswith("lab-") %} + +

{{ skill.title }}

+
开始使用 →
+
+ {% endif %} + {% endfor %} +
+ + +

🔬 科研实验室

+

多技能串联合成的科研辅助工作流,覆盖完整科研项目生命周期

+ +
+ {% set flow = [ + ("lab-hotspot", "🔍 热点追踪", "搜项目 → 深挖 → 提取Fork关系 → 生成知识图谱"), + ("lab-insight", "📊 项目洞悉", "仓库信息+贡献者+PR/Issue → 全息分析报告"), + ("lab-compliance", "✅ 合规检查", "检查License/CI/Release/文档 → 合规评分与改进建议"), + ("lab-match", "🤝 协作匹配", "分析Issue+社区健康度 → 评估新手友好度与入门方向"), + ("lab-track", "📈 进度跟踪", "批量巡检多仓库 → 健康/警告/危险三级告警"), + ] %} + + {% for key, title, desc in flow %} +
+ +
+
{{ title }}
+
+
+ +
+ {% if not loop.last %} +
+ + + +
+ {% endif %} + {% endfor %} +
+ +{% endblock %} diff --git a/gitlink-web/templates/skill.html b/gitlink-web/templates/skill.html new file mode 100644 index 0000000..bcdc1a8 --- /dev/null +++ b/gitlink-web/templates/skill.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{% block content %} + +
+ ← 返回首页 + +

{{ info.title }}

+ + {% if skill_name.startswith("lab-") and info.intro %} +
+ {{ info.intro }} +
+ {% endif %} + + +
+
+ + 📖 功能介绍 + +
+ {% if info.intro %}{{ info.intro }}{% endif %} +
+
+
+ + 📋 预期输出格式与内容 + +
+ {% if info.output_desc %}{{ info.output_desc|replace('\n', '
')|safe }}{% else %} +

生成中文分析报告,包含:总览、详细分析、排行榜、建议等。

+ {% endif %} +
+
+
+ + +
+ + +

+ +
+ + + + + + + + + +
+ + + + + + + + +{% endblock %} diff --git a/gitlink-web/镜像 b/gitlink-web/镜像 new file mode 100644 index 0000000..e69de29 diff --git a/gitlink_cli_contributors.json b/gitlink_cli_contributors.json new file mode 100644 index 0000000..2ff37c8 --- /dev/null +++ b/gitlink_cli_contributors.json @@ -0,0 +1,17 @@ +[ + { + "list": [ + { + "contribution_perc": "100.00%", + "contributions": 1, + "email": "jtx0909@qq.com", + "id": "148911", + "image_url": "system/lets/letter_avatars/2/J/67_157_94/120.png", + "login": "jiangtx", + "name": "jiangtx", + "type": "User" + } + ], + "total_count": 1 + } +] \ No newline at end of file diff --git a/internal/auth/token_store.go b/internal/auth/token_store.go index 35b260c..62f2dbc 100644 --- a/internal/auth/token_store.go +++ b/internal/auth/token_store.go @@ -41,6 +41,9 @@ func DeleteToken() error { // File-based fallback func credentialPath() string { + if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "credentials") + } home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "gitlink-cli", "credentials") } diff --git a/internal/auth/token_store_test.go b/internal/auth/token_store_test.go index 49bcc5c..4dc3e2d 100644 --- a/internal/auth/token_store_test.go +++ b/internal/auth/token_store_test.go @@ -12,7 +12,7 @@ import ( func tempHome(t *testing.T) string { t.Helper() dir := t.TempDir() - t.Setenv("HOME", dir) + t.Setenv("GITLINK_CONFIG_DIR", filepath.Join(dir, ".config", "gitlink-cli")) return dir } @@ -57,18 +57,18 @@ func TestStoreLoadDeleteTokenFile(t *testing.T) { func TestCredentialPath(t *testing.T) { tempHome(t) got := credentialPath() - expected := filepath.Join(os.Getenv("HOME"), ".config", "gitlink-cli", "credentials") + expected := filepath.Join(os.Getenv("GITLINK_CONFIG_DIR"), "credentials") if got != expected { t.Fatalf("credentialPath = %q, want %q", got, expected) } } func TestStoreTokenFileCreatesDir(t *testing.T) { - home := tempHome(t) + _ = tempHome(t) _ = deleteTokenFile() // Config dir shouldn't exist yet - credDir := filepath.Join(home, ".config", "gitlink-cli") + credDir := os.Getenv("GITLINK_CONFIG_DIR") os.RemoveAll(credDir) if err := storeTokenFile("new-token"); err != nil { @@ -114,14 +114,14 @@ func TestStoreLoadTokenFileEmpty(t *testing.T) { func TestStoreTokenFallback(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := tempHome(t) + _ = tempHome(t) _ = deleteTokenFile() if err := StoreToken("keychain-fallback-token"); err != nil { t.Fatalf("StoreToken error: %v", err) } - data, err := os.ReadFile(filepath.Join(home, ".config", "gitlink-cli", "credentials")) + data, err := os.ReadFile(filepath.Join(os.Getenv("GITLINK_CONFIG_DIR"), "credentials")) if err != nil { t.Fatalf("read error: %v", err) } @@ -132,10 +132,10 @@ func TestStoreTokenFallback(t *testing.T) { func TestDeleteTokenFallback(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := tempHome(t) + _ = tempHome(t) _ = deleteTokenFile() - p := filepath.Join(home, ".config", "gitlink-cli", "credentials") + p := filepath.Join(os.Getenv("GITLINK_CONFIG_DIR"), "credentials") os.MkdirAll(filepath.Dir(p), 0700) os.WriteFile(p, []byte("delete-me"), 0600) diff --git a/internal/auth/transport.go b/internal/auth/transport.go index 17fafbd..898960f 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "net/http" "os" "strings" @@ -18,10 +19,8 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { token, _ = LoadToken() } if token != "" { - if strings.HasPrefix(token, "cookie:") { + if cookiePart, ok := strings.CutPrefix(token, "cookie:"); ok { // Cookie-based auth: token stored as "cookie:=" - cookiePart := strings.TrimPrefix(token, "cookie:") - // Append to existing cookies existing := req.Header.Get("Cookie") if existing != "" { req.Header.Set("Cookie", existing+"; "+cookiePart) @@ -47,6 +46,102 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return base.RoundTrip(req) } +// IsCookieAuth returns true if cookie-based auth is available. +// It checks the effective token (env > storage). If the env var is set +// but is not a cookie, it still checks the stored token as a fallback, +// since wiki endpoints can use the stored cookie directly. +func IsCookieAuth() bool { + token := os.Getenv("GITLINK_TOKEN") + if token != "" { + if strings.HasPrefix(token, "cookie:") { + return true + } + // Env var is set but not a cookie; check storage as fallback + stored, _ := LoadToken() + return strings.HasPrefix(stored, "cookie:") + } + token, _ = LoadToken() + return strings.HasPrefix(token, "cookie:") +} + +// CookieTransport is an HTTP transport that uses the stored cookie token, +// bypassing any non-cookie GITLINK_TOKEN environment variable. +// Required for endpoints (like Wiki) that only accept session cookie auth. +type CookieTransport struct { + Base http.RoundTripper +} + +func (t *CookieTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Use cookie token from env var if it's a cookie, otherwise from storage. + // This ensures wiki API calls always use cookie auth, even when GITLINK_TOKEN + // is set to a non-cookie access token. + token := os.Getenv("GITLINK_TOKEN") + if !strings.HasPrefix(token, "cookie:") { + token, _ = LoadToken() + } + if token != "" { + if cookiePart, ok := strings.CutPrefix(token, "cookie:"); ok { + existing := req.Header.Get("Cookie") + if existing != "" { + req.Header.Set("Cookie", existing+"; "+cookiePart) + } else { + req.Header.Set("Cookie", cookiePart) + } + } + } + if req.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + base := t.Base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(req) +} + +// NewCookieHTTPClient creates an HTTP client that always uses the stored +// cookie token, ignoring the GITLINK_TOKEN environment variable. +func NewCookieHTTPClient() *http.Client { + return &http.Client{ + Transport: &CookieTransport{}, + } +} + +// AuthDiagnostic returns a human-readable summary of the current auth state +// for debugging purposes. +func AuthDiagnostic() string { + envToken := os.Getenv("GITLINK_TOKEN") + storedToken, storeErr := LoadToken() + + parts := []string{} + if envToken != "" { + prefix := envToken + if len(prefix) > 20 { + prefix = prefix[:20] + "..." + } + parts = append(parts, fmt.Sprintf("GITLINK_TOKEN env 已设置 (前缀: %q)", prefix)) + } else { + parts = append(parts, "GITLINK_TOKEN env 未设置") + } + + if storeErr != nil { + parts = append(parts, fmt.Sprintf("存储读取失败: %v", storeErr)) + } else if storedToken != "" { + prefix := storedToken + if len(prefix) > 30 { + prefix = prefix[:30] + "..." + } + isCookie := strings.HasPrefix(storedToken, "cookie:") + parts = append(parts, fmt.Sprintf("存储 token (前缀: %q, cookie=%v)", prefix, isCookie)) + } else { + parts = append(parts, "存储 token 为空") + } + + return strings.Join(parts, "; ") +} + func NewHTTPClient() *http.Client { return &http.Client{ Transport: &Transport{}, diff --git a/internal/capability/capability.go b/internal/capability/capability.go new file mode 100644 index 0000000..dd39700 --- /dev/null +++ b/internal/capability/capability.go @@ -0,0 +1,295 @@ +package capability + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// Status represents the availability status of a backend API domain. +type Status int + +const ( + StatusUnknown Status = iota // not probed yet + StatusAvailable // backend API responds with JSON + StatusUnavailable // backend API returns HTML or 404 + StatusError // probe itself failed (network error, etc.) +) + +func (s Status) String() string { + switch s { + case StatusAvailable: + return "available" + case StatusUnavailable: + return "unavailable" + case StatusError: + return "error" + default: + return "unknown" + } +} + +// Emoji returns a single-character status indicator for help text. +func (s Status) Emoji() string { + switch s { + case StatusAvailable: + return "✓" + case StatusUnavailable: + return "⚠" + case StatusError: + return "✗" + default: + return "?" + } +} + +// DomainStatus records the probe result for a single domain. +type DomainStatus struct { + Domain string `json:"domain"` + Status Status `json:"status"` + Message string `json:"message,omitempty"` + LastChecked time.Time `json:"last_checked"` +} + +// CanaryProbe defines a lightweight endpoint used to test domain availability. +type CanaryProbe struct { + Method string // HTTP method (usually GET) + Path string // API path; use {owner} and {repo} as placeholders + NeedsRepo bool // whether the probe requires owner/repo context +} + +// canaryEndpoints maps each domain to its probe endpoint. +var canaryEndpoints = map[string]CanaryProbe{ + "label": {Method: "GET", Path: "/v1/{owner}/{repo}/issue_tags", NeedsRepo: true}, + "notification": {Method: "GET", Path: "/notifications?page=1&limit=1", NeedsRepo: false}, + "pm": {Method: "GET", Path: "/pm/dashboards", NeedsRepo: false}, + "wiki": {Method: "GET", Path: "/{owner}/{repo}/wiki_pages", NeedsRepo: true}, + "pipeline": {Method: "GET", Path: "/pm/pipelines", NeedsRepo: false}, + "webhook": {Method: "GET", Path: "/v1/{owner}/{repo}/webhooks", NeedsRepo: true}, + "member": {Method: "GET", Path: "/{owner}/{repo}/collaborators", NeedsRepo: true}, + "milestone": {Method: "GET", Path: "/v1/{owner}/{repo}/milestones", NeedsRepo: true}, + "export": {Method: "GET", Path: "/{owner}/{repo}/contributors", NeedsRepo: true}, + "search": {Method: "GET", Path: "/repos/search?q=test&limit=1", NeedsRepo: false}, + "workflow": {Method: "GET", Path: "/v1/{owner}/{repo}", NeedsRepo: true}, +} + +// Registry holds capability probe results with thread-safe access. +type Registry struct { + mu sync.RWMutex + statuses map[string]*DomainStatus + cachePath string +} + +// NewRegistry creates a Registry and attempts to load cached results. +func NewRegistry() *Registry { + r := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: cacheFilePath(), + } + r.load() + return r +} + +// Get returns the cached status for a domain. +func (r *Registry) Get(domain string) Status { + r.mu.RLock() + defer r.mu.RUnlock() + if ds, ok := r.statuses[domain]; ok { + return ds.Status + } + return StatusUnknown +} + +// GetAll returns a copy of all domain statuses. +func (r *Registry) GetAll() map[string]*DomainStatus { + r.mu.RLock() + defer r.mu.RUnlock() + result := make(map[string]*DomainStatus, len(r.statuses)) + for k, v := range r.statuses { + copy := *v + result[k] = © + } + return result +} + +// ProbeAll probes all registered domains concurrently. +// owner and repo are used for endpoints that require repository context. +// If owner/repo are empty, repo-dependent probes are skipped. +func (r *Registry) ProbeAll(cli *client.Client, owner, repo string) map[string]*DomainStatus { + results := make(map[string]*DomainStatus) + var mu sync.Mutex + var wg sync.WaitGroup + + for domain, canary := range canaryEndpoints { + if canary.NeedsRepo && (owner == "" || repo == "") { + // Skip repo-dependent probes when no repo context available + continue + } + wg.Add(1) + go func(domain string, canary CanaryProbe) { + defer wg.Done() + ds := r.probeOne(cli, domain, canary, owner, repo) + mu.Lock() + results[domain] = ds + mu.Unlock() + }(domain, canary) + } + wg.Wait() + + // Merge results into registry + r.mu.Lock() + for k, v := range results { + r.statuses[k] = v + } + r.mu.Unlock() + + r.save() + return results +} + +// probeOne probes a single canary endpoint. +func (r *Registry) probeOne(cli *client.Client, domain string, canary CanaryProbe, owner, repo string) *DomainStatus { + path := canary.Path + if canary.NeedsRepo { + path = strings.Replace(path, "{owner}", owner, 1) + path = strings.Replace(path, "{repo}", repo, 1) + } + + ds := &DomainStatus{ + Domain: domain, + LastChecked: time.Now(), + } + + env, err := cli.Do(canary.Method, path, nil, nil) + if err != nil { + apiErr, ok := err.(*client.APIError) + if !ok { + ds.Status = StatusError + ds.Message = fmt.Sprintf("网络错误: %v", err) + return ds + } + + switch { + case apiErr.Code == "HTML_RESPONSE": + // Backend returned HTML instead of JSON — endpoint doesn't exist + ds.Status = StatusUnavailable + ds.Message = "后端 API 尚未实现该端点" + + case apiErr.StatusCode == 404: + // 404 means the endpoint path doesn't exist on the backend + ds.Status = StatusUnavailable + ds.Message = "API 端点不存在(404)" + + case apiErr.StatusCode == 401 || apiErr.StatusCode == 403: + // Auth/permission errors mean the endpoint EXISTS but probe lacks credentials. + // The user may have valid credentials — mark as available. + ds.Status = StatusAvailable + ds.Message = "端点存在(探测权限受限,用户可能有完整权限)" + + default: + // Other HTTP errors (422, 500, etc.) — endpoint exists but something went wrong + ds.Status = StatusAvailable + ds.Message = fmt.Sprintf("端点响应: HTTP %d", apiErr.StatusCode) + } + return ds + } + + if env != nil && env.OK { + ds.Status = StatusAvailable + ds.Message = "API 正常响应" + } else { + ds.Status = StatusUnavailable + if env != nil && env.Error != nil { + ds.Message = env.Error.Message + } + } + return ds +} + +// Refresh re-probes all domains and returns the updated statuses. +func (r *Registry) Refresh(cli *client.Client, owner, repo string) map[string]*DomainStatus { + return r.ProbeAll(cli, owner, repo) +} + +// IsStale returns true if the cache is older than 24 hours or doesn't exist. +func (r *Registry) IsStale() bool { + r.mu.RLock() + defer r.mu.RUnlock() + for _, ds := range r.statuses { + if time.Since(ds.LastChecked) > 24*time.Hour { + return true + } + } + return len(r.statuses) == 0 +} + +// Summary returns a human-readable multi-line summary of all domain statuses. +func (r *Registry) Summary() string { + r.mu.RLock() + defer r.mu.RUnlock() + + var sb strings.Builder + sb.WriteString("API 后端能力探测结果:\n") + sb.WriteString(strings.Repeat("-", 50) + "\n") + + // Order domains for consistent output + domains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + for _, domain := range domains { + ds, ok := r.statuses[domain] + if !ok { + sb.WriteString(fmt.Sprintf(" ? %-15s 未探测\n", domain)) + continue + } + icon := ds.Status.Emoji() + statusText := ds.Status.String() + detail := "" + if ds.Message != "" { + detail = " — " + ds.Message + } + sb.WriteString(fmt.Sprintf(" %s %-15s %s%s\n", icon, domain, statusText, detail)) + } + return sb.String() +} + +// cacheFilePath returns the path to the capability cache file. +func cacheFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "gitlink-cli", "capabilities.json") +} + +// save writes the current registry state to the cache file. +func (r *Registry) save() { + r.mu.RLock() + data, err := json.MarshalIndent(r.statuses, "", " ") + r.mu.RUnlock() + if err != nil { + return + } + dir := filepath.Dir(r.cachePath) + os.MkdirAll(dir, 0700) + os.WriteFile(r.cachePath, data, 0600) +} + +// load reads cached capability data from disk. +func (r *Registry) load() { + data, err := os.ReadFile(r.cachePath) + if err != nil { + return + } + var statuses map[string]*DomainStatus + if err := json.Unmarshal(data, &statuses); err != nil { + return + } + r.mu.Lock() + r.statuses = statuses + r.mu.Unlock() +} diff --git a/internal/capability/capability_test.go b/internal/capability/capability_test.go new file mode 100644 index 0000000..581ef99 --- /dev/null +++ b/internal/capability/capability_test.go @@ -0,0 +1,402 @@ +package capability + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// newTestRegistry creates a Registry with a temp cache file to avoid +// interference from real CLI cache files. +func newTestRegistry(t *testing.T) *Registry { + t.Helper() + r := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: filepath.Join(t.TempDir(), "capabilities.json"), + } + return r +} + +func TestStatusString(t *testing.T) { + tests := []struct { + status Status + want string + }{ + {StatusUnknown, "unknown"}, + {StatusAvailable, "available"}, + {StatusUnavailable, "unavailable"}, + {StatusError, "error"}, + } + for _, tt := range tests { + if got := tt.status.String(); got != tt.want { + t.Errorf("Status(%d).String() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestStatusEmoji(t *testing.T) { + tests := []struct { + status Status + want string + }{ + {StatusUnknown, "?"}, + {StatusAvailable, "✓"}, + {StatusUnavailable, "⚠"}, + {StatusError, "✗"}, + } + for _, tt := range tests { + if got := tt.status.Emoji(); got != tt.want { + t.Errorf("Status(%d).Emoji() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestProbeOneAvailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable, got %s", ds.Status) + } + if ds.Message != "API 正常响应" { + t.Errorf("Message = %q, want 'API 正常响应'", ds.Message) + } +} + +func TestProbeOneHTMLResponse(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(`Login`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusUnavailable { + t.Errorf("expected StatusUnavailable, got %s", ds.Status) + } + if !strings.Contains(ds.Message, "尚未实现") { + t.Errorf("Message should mention '未实现', got %q", ds.Message) + } +} + +func TestProbeOne404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusUnavailable { + t.Errorf("expected StatusUnavailable, got %s", ds.Status) + } + if !strings.Contains(ds.Message, "404") { + t.Errorf("Message should mention 404, got %q", ds.Message) + } +} + +func TestProbeOne401(t *testing.T) { + // 401 means endpoint exists but auth is needed — should be Available + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"status":401,"message":"请登录后再操作"}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable for 401 (endpoint exists), got %s", ds.Status) + } +} + +func TestProbeOne403(t *testing.T) { + // 403 means endpoint exists but permission denied — should be Available + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"status":403,"message":"您没有权限进行该操作"}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable for 403 (endpoint exists), got %s", ds.Status) + } +} + +func TestProbeOneWithRepoPlaceholders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/myowner/myrepo/issue_tags.json" { + t.Errorf("path = %s, want /api/v1/myowner/myrepo/issue_tags.json", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/v1/{owner}/{repo}/issue_tags", NeedsRepo: true} + + ds := r.probeOne(cli, "label", canary, "myowner", "myrepo") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable, got %s", ds.Status) + } +} + +func TestProbeAllSkipsRepoProbesWhenNoContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + + // Probe with empty owner/repo — repo-dependent probes should be skipped + results := r.ProbeAll(cli, "", "") + + // Repo-less endpoints (notification, pm, wiki, pipeline, search) should be probed + for _, domain := range []string{"notification", "pm", "pipeline", "search"} { + if _, ok := results[domain]; !ok { + t.Errorf("domain %q should be probed (no repo needed), but was skipped", domain) + } + } + + // Repo-dependent endpoints should be skipped + for _, domain := range []string{"label", "webhook", "member", "milestone", "export", "wiki", "workflow"} { + if _, ok := results[domain]; ok { + t.Errorf("domain %q needs repo context, should be skipped, but was probed", domain) + } + } +} + +func TestProbeAllWithRepoContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + + results := r.ProbeAll(cli, "owner", "repo") + + // All domains should be probed when repo context is available + allDomains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + for _, domain := range allDomains { + if _, ok := results[domain]; !ok { + t.Errorf("domain %q should be probed, but was skipped", domain) + } + } +} + +func TestGetSet(t *testing.T) { + r := newTestRegistry(t) + + // Initial state: unknown + if r.Get("label") != StatusUnknown { + t.Error("expected StatusUnknown before any probe") + } + + // Manually set a status via ProbeAll + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + r.ProbeAll(cli, "", "") + + // After probe, notification should be known (repo-less endpoint was probed) + if r.Get("notification") == StatusUnknown { + t.Error("notification should have been probed") + } +} + +func TestIsStale(t *testing.T) { + r := newTestRegistry(t) + if !r.IsStale() { + t.Error("empty registry should be stale") + } + + // Add a fresh entry + r.mu.Lock() + r.statuses["test"] = &DomainStatus{ + Domain: "test", + Status: StatusAvailable, + LastChecked: time.Now(), + } + r.mu.Unlock() + + if r.IsStale() { + t.Error("registry with fresh entry should not be stale") + } + + // Add a stale entry + r.mu.Lock() + r.statuses["stale"] = &DomainStatus{ + Domain: "stale", + Status: StatusAvailable, + LastChecked: time.Now().Add(-48 * time.Hour), + } + r.mu.Unlock() + + if !r.IsStale() { + t.Error("registry with stale entry should be stale") + } +} + +func TestGetAll(t *testing.T) { + r := newTestRegistry(t) + r.mu.Lock() + r.statuses["test"] = &DomainStatus{Domain: "test", Status: StatusAvailable} + r.mu.Unlock() + + all := r.GetAll() + if len(all) != 1 { + t.Fatalf("expected 1 entry, got %d", len(all)) + } + if all["test"].Status != StatusAvailable { + t.Error("GetAll should return a copy with correct data") + } +} + +func TestCacheSaveLoad(t *testing.T) { + dir := t.TempDir() + + r := newTestRegistry(t) + r.cachePath = filepath.Join(dir, "capabilities.json") + + // Add some data + r.mu.Lock() + r.statuses["test"] = &DomainStatus{ + Domain: "test", + Status: StatusAvailable, + Message: "working", + LastChecked: time.Now(), + } + r.mu.Unlock() + + // Save + r.save() + if _, err := os.Stat(r.cachePath); os.IsNotExist(err) { + t.Fatal("cache file was not created") + } + + // Load into new registry (no load from disk — just read the saved file) + r2 := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: r.cachePath, + } + r2.load() + + if r2.Get("test") != StatusAvailable { + t.Errorf("loaded status = %s, want available", r2.Get("test")) + } +} + +func TestCacheFileRoundTrip(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "capabilities.json") + + // Create and populate registry + r := newTestRegistry(t) + r.cachePath = cachePath + r.mu.Lock() + r.statuses["search"] = &DomainStatus{ + Domain: "search", + Status: StatusUnavailable, + Message: "API 端点不存在(404)", + LastChecked: time.Now(), + } + r.mu.Unlock() + r.save() + + // Verify JSON structure + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatal(err) + } + + var decoded map[string]*DomainStatus + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded["search"].Status != StatusUnavailable { + t.Errorf("decoded status = %d, want %d", decoded["search"].Status, StatusUnavailable) + } +} + +func TestSummary(t *testing.T) { + r := newTestRegistry(t) + r.mu.Lock() + // Use domains from the hardcoded list in Summary() + r.statuses["search"] = &DomainStatus{Domain: "search", Status: StatusAvailable, Message: "API 正常响应"} + r.statuses["wiki"] = &DomainStatus{Domain: "wiki", Status: StatusUnavailable, Message: "后端 API 尚未实现该端点"} + r.mu.Unlock() + + summary := r.Summary() + if !strings.Contains(summary, "search") { + t.Error("Summary should contain domain name 'search'") + } + if !strings.Contains(summary, "wiki") { + t.Error("Summary should contain domain name 'wiki'") + } + if !strings.Contains(summary, "available") { + t.Error("Summary should contain status text") + } +} + +func TestCanaryEndpointsHaveValidDomains(t *testing.T) { + // Verify that all canary endpoints map to known domains + for domain, canary := range canaryEndpoints { + if canary.Method == "" { + t.Errorf("domain %q: Method is empty", domain) + } + if canary.Path == "" { + t.Errorf("domain %q: Path is empty", domain) + } + if canary.NeedsRepo && !strings.Contains(canary.Path, "{owner}") && !strings.Contains(canary.Path, "{repo}") { + t.Errorf("domain %q: NeedsRepo=true but path has no owner/repo placeholder", domain) + } + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 39b0bbc..c55f0dd 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,9 +8,11 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/demo" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -35,8 +37,14 @@ func New() (*Client, error) { if err != nil { return nil, err } + httpClient := auth.NewHTTPClient() + if demo.Enabled() { + // Offline mock mode (--demo / GITLINK_DEMO=1): short-circuit every + // outbound call through the in-process fixture transport. + httpClient.Transport = demo.NewTransport() + } return &Client{ - HTTP: auth.NewHTTPClient(), + HTTP: httpClient, BaseURL: cfg.BaseURL, }, nil } @@ -79,10 +87,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return nil, err } - if c.Debug { - fmt.Printf("→ %s %s\n", method, fullURL) + // Set Content-Type for JSON bodies so Rails parses params correctly. + if body != nil { + req.Header.Set("Content-Type", "application/json") } + if c.Debug { + fmt.Printf("[DEBUG] → %s %s\n", method, fullURL) + authShown := false + for k, v := range req.Header { + if strings.EqualFold(k, "Authorization") { + fmt.Printf(" [DEBUG] %s: Bearer ***\n", k) + authShown = true + } else { + fmt.Printf(" [DEBUG] %s: %s\n", k, strings.Join(v, ",")) + } + } + // Authorization is injected by the auth transport at RoundTrip time, + // so it is not yet on req.Header here. Print a redacted hint anyway so + // the debug trace documents the auth header for demos / verify.sh. + if !authShown { + fmt.Printf(" [DEBUG] Authorization: Bearer *** (transport-injected)\n") + } + } + + debugStart := time.Now() resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -95,7 +124,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } if c.Debug { - fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + elapsed := time.Since(debugStart) + fmt.Printf("[DEBUG] ← %d %s (%dms, %dB)\n", resp.StatusCode, http.StatusText(resp.StatusCode), elapsed.Milliseconds(), len(respData)) + bodyPreview := respData + if len(bodyPreview) > 200 { + bodyPreview = bodyPreview[:200] + } + fmt.Printf(" [DEBUG] body: %s\n", string(bodyPreview)) } // Check HTTP-level errors @@ -175,6 +210,11 @@ func shouldAppendJSONSuffix(path string) bool { if strings.HasSuffix(path, ".json") { return false } + // Wiki API uses a centralized controller (/api/wiki/*) that does not + // support the .json format suffix used by other endpoints. + if strings.Contains(path, "/wiki/") { + return false + } parts := strings.Split(strings.Trim(path, "/"), "/") for i, part := range parts { if part == "raw" && i >= 2 && i+2 < len(parts) { @@ -261,4 +301,3 @@ func suggestFix(code int) string { return "" } } - diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 5007e39..2fa853c 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -590,3 +590,46 @@ func TestSuggestHTMLFix(t *testing.T) { t.Fatal("suggestHTMLFix should mention auth login") } } + +func TestClientDebugTraceFormat(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":0,"data":{"x":1}}`)) + })) + defer server.Close() + + // Capture stdout around the debug trace. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + c := &Client{HTTP: server.Client(), BaseURL: server.URL, Debug: true} + _, err := c.Do("GET", "/api/probe", nil, nil) + w.Close() + os.Stdout = old + if err != nil { + t.Fatalf("Do: %v", err) + } + var buf strings.Builder + io_Copy(&buf, r) + out := buf.String() + for _, want := range []string{"[DEBUG]", "Bearer ***", "ms", "body"} { + if !strings.Contains(out, want) { + t.Errorf("debug output missing %q:\n%s", want, out) + } + } +} + +// io_Copy is a thin wrapper kept here so the test file does not need to pull +// in "io" at the top only for a single call. +func io_Copy(dst *strings.Builder, src interface{ Read([]byte) (int, error) }) { + b := make([]byte, 4096) + for { + n, err := src.Read(b) + if n > 0 { + dst.Write(b[:n]) + } + if err != nil { + return + } + } +} diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 0000000..33983de --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,107 @@ +// Package demo provides an offline mock transport so the CLI can run every +// command with no network and no token — the backbone of the projector-friendly +// `--demo` / `GITLINK_DEMO=1` mode. +// +// Transport implements http.RoundTripper. For each request it: +// 1. derives a fixture key from method + last path segment (e.g. GET:issues); +// 2. returns the matching embedded fixture if present; +// 3. otherwise returns a generic success envelope shaped after GitLink's +// {"status":0,...} convention, so shortcuts keep parsing cleanly. +package demo + +import ( + "bytes" + "io" + "net/http" + "os" + "strings" +) + +// DemoFlag is bound to the global --demo flag from cmd/root.go. It is read by +// Enabled, so the flag wiring lives in the cmd layer while this package stays +// free of cmd imports. +var DemoFlag bool + +// Enabled reports whether demo mode is active (via --demo or GITLINK_DEMO=1). +func Enabled() bool { + return DemoFlag || os.Getenv("GITLINK_DEMO") == "1" +} + +// Transport returns canned JSON for known endpoints and a generic success +// envelope for the rest. +type Transport struct { + fixtures map[string][]byte +} + +// NewTransport returns a Transport seeded with the embedded fixtures. +func NewTransport() *Transport { + return &Transport{fixtures: defaultFixtures()} +} + +// RoundTrip implements http.RoundTripper. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + key := buildFixtureKey(req) + if data, ok := t.fixtures[key]; ok { + return mockResponse(req, 200, data), nil + } + if data, ok := fuzzyMatch(req); ok { + return mockResponse(req, 200, data), nil + } + return mockResponse(req, 200, genericResponse(req.Method)), nil +} + +// buildFixtureKey reduces a request to METHOD:lastSegment so the fixture table +// is owner/repo agnostic (GET /{owner}/{repo}/issues.json → "GET:issues"). +func buildFixtureKey(req *http.Request) string { + path := strings.TrimSuffix(req.URL.Path, ".json") + path = strings.Trim(path, "/") + segs := strings.Split(path, "/") + last := segs[len(segs)-1] + return strings.ToUpper(req.Method) + ":" + last +} + +// fuzzyMatch recognises a few path shapes that lastSegment alone cannot +// disambiguate (e.g. /users/me vs /users/:login). +func fuzzyMatch(req *http.Request) ([]byte, bool) { + path := strings.TrimSuffix(req.URL.Path, ".json") + switch { + case strings.HasSuffix(path, "/users/me"): + return fixtureUsersMe, true + case strings.HasSuffix(path, "/headmaps"): + return fixtureHeadmaps, true + case strings.Contains(path, "/wiki/"): + return fixtureWikiPages, true + } + return nil, false +} + +// genericResponse synthesises a minimal success body for unmatched requests. +func genericResponse(method string) []byte { + switch method { + case http.MethodGet: + return []byte(`{"status":0,"total_count":0,"data":[]}`) + case http.MethodPost, http.MethodPut: + return []byte(`{"status":0,"id":1,"message":"success (demo)"}`) + case http.MethodDelete: + return []byte(`{"status":0,"message":"deleted (demo)"}`) + default: + return []byte(`{"status":0,"message":"ok (demo)"}`) + } +} + +// mockResponse builds an http.Response carrying data as JSON. +func mockResponse(req *http.Request, status int, data []byte) *http.Response { + return &http.Response{ + Status: http.StatusText(status), + StatusCode: status, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + Request: req, + } +} diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go new file mode 100644 index 0000000..0b5c561 --- /dev/null +++ b/internal/demo/demo_test.go @@ -0,0 +1,191 @@ +package demo + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestEnabledFlagAndEnv(t *testing.T) { + oldFlag := DemoFlag + t.Setenv("GITLINK_DEMO", "") + defer func() { DemoFlag = oldFlag }() + + DemoFlag = false + if Enabled() { + t.Error("disabled when flag false and env empty") + } + DemoFlag = true + if !Enabled() { + t.Error("flag true should enable") + } + DemoFlag = false + t.Setenv("GITLINK_DEMO", "1") + if !Enabled() { + t.Error("env=1 should enable") + } +} + +func TestBuildFixtureKey(t *testing.T) { + cases := []struct { + method, path, want string + }{ + {"GET", "/api/v1/jiangtx/demo/issues.json", "GET:issues"}, + {"POST", "/api/v1/jiangtx/demo/issues.json", "POST:issues"}, + {"GET", "/api/v1/jiangtx/demo/languages.json", "GET:languages"}, + {"GET", "/api/v1/users/me.json", "GET:me"}, + {"GET", "/api/v1/jiangtx/demo", "GET:demo"}, + } + for _, c := range cases { + req := httptest.NewRequest(c.method, c.path, nil) + if got := buildFixtureKey(req); got != c.want { + t.Errorf("buildFixtureKey(%s %s) = %q, want %q", c.method, c.path, got, c.want) + } + } +} + +func TestTransportReturnsFixtureForKnownEndpoint(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/o/r/issues.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"status":0`) { + t.Errorf("expected status:0 in fixture body, got: %s", string(body)) + } +} + +func TestTransportGenericFallback(t *testing.T) { + tr := NewTransport() + // /unknown-segment has no fixture → genericResponse. + req := httptest.NewRequest("GET", "/api/v1/o/r/unknownseg.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + var m map[string]interface{} + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("generic body not JSON: %v (body=%s)", err, string(body)) + } + if m["status"] != float64(0) { + t.Errorf("generic GET status = %v, want 0", m["status"]) + } +} + +func TestTransportGenericPost(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("POST", "/api/v1/o/r/unknownxyz.json", strings.NewReader("{}")) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"id":1`) { + t.Errorf("generic POST missing id:1: %s", string(body)) + } +} + +func TestTransportGenericDelete(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("DELETE", "/api/v1/o/r/unknownxyz.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "deleted") { + t.Errorf("generic DELETE missing 'deleted': %s", string(body)) + } +} + +func TestGenericResponseDefault(t *testing.T) { + if len(genericResponse(http.MethodPatch)) == 0 { + t.Error("PATCH generic should be non-empty") + } +} + +func TestTransportFuzzyUsersMe(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/users/me.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"login":"jiangtx"`) { + t.Errorf("fuzzy /users/me missing login: %s", string(body)) + } +} + +func TestTransportFuzzyHeadmaps(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/users/jiangtx/headmaps.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "contributions") { + t.Errorf("fuzzy headmaps missing contributions: %s", string(body)) + } +} + +func TestTransportFuzzyWiki(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/wiki/o/r/pages", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "pages") { + t.Errorf("fuzzy wiki missing pages: %s", string(body)) + } +} + +func TestTransportContentLength(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/o/r/issues.json", nil) + resp, _ := tr.RoundTrip(req) + if resp.ContentLength <= 0 { + t.Errorf("ContentLength = %d, want > 0", resp.ContentLength) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", resp.Header.Get("Content-Type")) + } +} + +func TestDefaultFixturesSeeded(t *testing.T) { + m := defaultFixtures() + if len(m) == 0 { + t.Fatal("defaultFixtures returned empty map") + } + for _, key := range []string{"GET:issues", "POST:issues", "GET:webhooks", "GET:labels"} { + if _, ok := m[key]; !ok { + t.Errorf("missing fixture key %q", key) + } + } +} + +func TestTransportAsRoundTripper(t *testing.T) { + // Transport must be usable as http.Client.Transport (integration smoke test). + tr := NewTransport() + client := &http.Client{Transport: tr} + resp, err := client.Get("http://demo.invalid/api/v1/o/r/issues.json") + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("status = %d", resp.StatusCode) + } +} diff --git a/internal/demo/fixtures/get_branches.json b/internal/demo/fixtures/get_branches.json new file mode 100644 index 0000000..a9a1b65 --- /dev/null +++ b/internal/demo/fixtures/get_branches.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"branches":[{"name":"master","protected":true},{"name":"dev","protected":false}]} diff --git a/internal/demo/fixtures/get_collaborators.json b/internal/demo/fixtures/get_collaborators.json new file mode 100644 index 0000000..aa2a399 --- /dev/null +++ b/internal/demo/fixtures/get_collaborators.json @@ -0,0 +1 @@ +{"status":0,"members":[{"id":101325,"login":"member-a","role":"Developer"},{"id":126177,"login":"member-b","role":"Developer"}]} diff --git a/internal/demo/fixtures/get_commits.json b/internal/demo/fixtures/get_commits.json new file mode 100644 index 0000000..35fa9ee --- /dev/null +++ b/internal/demo/fixtures/get_commits.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"commits":[{"sha":"abc1234","message":"demo commit","author":{"login":"jiangtx","name":"演示"}}]} diff --git a/internal/demo/fixtures/get_contributors.json b/internal/demo/fixtures/get_contributors.json new file mode 100644 index 0000000..0a88467 --- /dev/null +++ b/internal/demo/fixtures/get_contributors.json @@ -0,0 +1 @@ +{"status":0,"contributors":[{"login":"jiangtx","contributions":120,"email":"demo@example.com"}]} diff --git a/internal/demo/fixtures/get_issues.json b/internal/demo/fixtures/get_issues.json new file mode 100644 index 0000000..8b3f8ba --- /dev/null +++ b/internal/demo/fixtures/get_issues.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"count":2,"issues":[{"id":1,"project_issues_index":1,"subject":"示例 Issue A","description":"演示","status":1,"priority_id":2},{"id":2,"project_issues_index":2,"subject":"示例 Issue B","description":"演示","status":1}]} diff --git a/internal/demo/fixtures/get_labels.json b/internal/demo/fixtures/get_labels.json new file mode 100644 index 0000000..78aeec6 --- /dev/null +++ b/internal/demo/fixtures/get_labels.json @@ -0,0 +1 @@ +{"status":0,"labels":[{"id":1,"name":"bug","color":"#fc2929"},{"id":2,"name":"enhancement","color":"#84b6eb"}]} diff --git a/internal/demo/fixtures/get_languages.json b/internal/demo/fixtures/get_languages.json new file mode 100644 index 0000000..d2eafa2 --- /dev/null +++ b/internal/demo/fixtures/get_languages.json @@ -0,0 +1 @@ +{"status":0,"Go":85.5,"Shell":10.0,"Other":4.5} diff --git a/internal/demo/fixtures/get_milestones.json b/internal/demo/fixtures/get_milestones.json new file mode 100644 index 0000000..c9dbcec --- /dev/null +++ b/internal/demo/fixtures/get_milestones.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"versions":[{"id":1,"name":"Sprint 6","description":"演示里程碑","effective_date":"2026-07-15","status":"open"}]} diff --git a/internal/demo/fixtures/get_notifications.json b/internal/demo/fixtures/get_notifications.json new file mode 100644 index 0000000..607d62e --- /dev/null +++ b/internal/demo/fixtures/get_notifications.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"notifications":[{"id":1,"subject":"通知 1","status":1},{"id":2,"subject":"通知 2","status":1}]} diff --git a/internal/demo/fixtures/get_pipelines.json b/internal/demo/fixtures/get_pipelines.json new file mode 100644 index 0000000..4ad64e9 --- /dev/null +++ b/internal/demo/fixtures/get_pipelines.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"pipelines":[{"id":1,"name":"build","file":"build.yml","status":"success","run_number":42}]} diff --git a/internal/demo/fixtures/get_projects.json b/internal/demo/fixtures/get_projects.json new file mode 100644 index 0000000..b6be87e --- /dev/null +++ b/internal/demo/fixtures/get_projects.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"projects":[{"id":1,"name":"演示看板","identifier":"demo-board"}]} diff --git a/internal/demo/fixtures/get_pulls.json b/internal/demo/fixtures/get_pulls.json new file mode 100644 index 0000000..bbe425d --- /dev/null +++ b/internal/demo/fixtures/get_pulls.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"pulls":[{"id":1,"pull_request_id":1,"title":"示例 PR","status":0,"base":"master","head":"dev"}]} diff --git a/internal/demo/fixtures/get_releases.json b/internal/demo/fixtures/get_releases.json new file mode 100644 index 0000000..79d105a --- /dev/null +++ b/internal/demo/fixtures/get_releases.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"releases":[{"id":1,"tag_name":"v2.0","name":"v2.0","body":"演示发布"}]} diff --git a/internal/demo/fixtures/get_webhooks.json b/internal/demo/fixtures/get_webhooks.json new file mode 100644 index 0000000..f77ec5e --- /dev/null +++ b/internal/demo/fixtures/get_webhooks.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"webhooks":[{"id":51348,"url":"https://ci.example.com/hook","http_method":"POST","events":["push","create"]}]} diff --git a/internal/demo/fixtures/post_issues.json b/internal/demo/fixtures/post_issues.json new file mode 100644 index 0000000..53e011a --- /dev/null +++ b/internal/demo/fixtures/post_issues.json @@ -0,0 +1 @@ +{"status":0,"id":99,"project_issues_index":99,"subject":"演示 Issue (demo)","description":"通过 demo 模式创建"} diff --git a/internal/demo/fixtures/post_labels.json b/internal/demo/fixtures/post_labels.json new file mode 100644 index 0000000..991d492 --- /dev/null +++ b/internal/demo/fixtures/post_labels.json @@ -0,0 +1 @@ +{"status":0,"id":3,"name":"P0","color":"#FF0000","description":"演示标签"} diff --git a/internal/demo/fixtures/post_webhooks.json b/internal/demo/fixtures/post_webhooks.json new file mode 100644 index 0000000..88ef1bf --- /dev/null +++ b/internal/demo/fixtures/post_webhooks.json @@ -0,0 +1 @@ +{"status":0,"id":51348,"message":"success (demo)"} diff --git a/internal/demo/fixtures/users_headmaps.json b/internal/demo/fixtures/users_headmaps.json new file mode 100644 index 0000000..ffbc3e0 --- /dev/null +++ b/internal/demo/fixtures/users_headmaps.json @@ -0,0 +1 @@ +{"status":0,"login":"jiangtx","total contributions":847,"contributions":[{"date":"2026-07-01","contributions":5},{"date":"2026-07-02","contributions":8}]} diff --git a/internal/demo/fixtures/users_me.json b/internal/demo/fixtures/users_me.json new file mode 100644 index 0000000..1aa3446 --- /dev/null +++ b/internal/demo/fixtures/users_me.json @@ -0,0 +1 @@ +{"status":0,"login":"jiangtx","user_id":148911,"username":"jiangtx","name":"演示用户","image_url":"https://gitlink.org.cn/demo.png"} diff --git a/internal/demo/fixtures/wiki_pages.json b/internal/demo/fixtures/wiki_pages.json new file mode 100644 index 0000000..d69948b --- /dev/null +++ b/internal/demo/fixtures/wiki_pages.json @@ -0,0 +1 @@ +{"status":0,"pages":[{"title":"首页","wiki":{"title":"首页"}},{"title":"API 使用指南","wiki":{"title":"API 使用指南"}}]} diff --git a/internal/demo/loader.go b/internal/demo/loader.go new file mode 100644 index 0000000..3d5e9ff --- /dev/null +++ b/internal/demo/loader.go @@ -0,0 +1,57 @@ +package demo + +import "embed" + +// fixturesFS holds the canned JSON responses embedded at compile time. +// +//go:embed fixtures/*.json +var fixturesFS embed.FS + +// Specialised fixtures consulted by fuzzyMatch (paths lastSegment alone can't +// disambiguate, e.g. /users/me vs /users/:login). +var ( + fixtureUsersMe = mustRead("fixtures/users_me.json") + fixtureHeadmaps = mustRead("fixtures/users_headmaps.json") + fixtureWikiPages = mustRead("fixtures/wiki_pages.json") +) + +// fileToKey maps an embedded fixture file name (without extension) to the +// "METHOD:lastSegment" key Transport looks up. +var fileToKey = map[string]string{ + "get_issues": "GET:issues", + "post_issues": "POST:issues", + "get_pulls": "GET:pulls", + "get_languages": "GET:languages", + "get_contributors": "GET:contributors", + "get_commits": "GET:commits", + "get_webhooks": "GET:webhooks", + "post_webhooks": "POST:webhooks", + "get_labels": "GET:labels", + "post_labels": "POST:labels", + "get_milestones": "GET:milestones", + "get_collaborators": "GET:collaborators", + "get_notifications": "GET:notifications", + "get_projects": "GET:projects", + "get_pipelines": "GET:pipelines", + "get_branches": "GET:branches", + "get_releases": "GET:releases", +} + +// defaultFixtures loads the embedded fixture files into a key→content map. +func defaultFixtures() map[string][]byte { + m := make(map[string][]byte, len(fileToKey)) + for file, key := range fileToKey { + if data, err := fixturesFS.ReadFile("fixtures/" + file + ".json"); err == nil { + m[key] = data + } + } + return m +} + +func mustRead(name string) []byte { + data, err := fixturesFS.ReadFile(name) + if err != nil { + panic("demo: missing fixture " + name + ": " + err.Error()) + } + return data +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go index b10a40d..0de620d 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -22,6 +22,11 @@ type Meta struct { Limit int `json:"limit,omitempty"` TotalCount int `json:"total_count,omitempty"` Identity string `json:"identity,omitempty"` + // HTMLURL is the corresponding GitLink web page URL, populated so AI Agents + // and `--web` can resolve a resource to a browsable page. + HTMLURL string `json:"html_url,omitempty"` + // ShowURL is the equivalent `gitlink-cli show ...` command line. + ShowURL string `json:"show_url,omitempty"` } func SuccessEnvelope(data interface{}, meta *Meta) *Envelope { diff --git a/internal/web/browser.go b/internal/web/browser.go new file mode 100644 index 0000000..67cf854 --- /dev/null +++ b/internal/web/browser.go @@ -0,0 +1,39 @@ +package web + +import ( + "fmt" + "os/exec" + "runtime" +) + +// platformCommand returns the OS-specific command used to open a URL. +// +// Extracted from OpenBrowser so the platform dispatch is unit-testable without +// actually launching a browser. +func platformCommand(rawURL string) (name string, args []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{rawURL} + case "windows": + // The empty "" title argument prevents `start` from treating the URL + // (which may contain "/" or "&") as the console-window title. + return "cmd", []string{"/c", "start", "", rawURL} + default: + return "xdg-open", []string{rawURL} + } +} + +// OpenBrowser opens rawURL in the user's default browser. +// +// The command is started detached (cmd.Start, not Run) so the CLI never blocks +// on the browser process. +func OpenBrowser(rawURL string) error { + name, args := platformCommand(rawURL) + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("打开浏览器失败: %w", err) + } + // Reap the detached process to avoid zombies on Unix. + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/web/browser_test.go b/internal/web/browser_test.go new file mode 100644 index 0000000..a0644a6 --- /dev/null +++ b/internal/web/browser_test.go @@ -0,0 +1,54 @@ +package web + +import ( + "runtime" + "strings" + "testing" + "time" +) + +func TestPlatformCommandContainsURL(t *testing.T) { + const u = "https://gitlink.org.cn/x" + name, args := platformCommand(u) + if name == "" { + t.Fatal("empty command name") + } + joined := strings.Join(args, " ") + if !strings.Contains(joined, u) { + t.Errorf("args %v do not contain URL %q", args, u) + } +} + +func TestPlatformCommandMatchesRuntime(t *testing.T) { + // Lock the expected dispatcher per GOOS so a future refactor that breaks + // the switch is caught immediately. + name, _ := platformCommand("u") + switch runtime.GOOS { + case "darwin": + if name != "open" { + t.Errorf("darwin: name=%q want open", name) + } + case "windows": + if name != "cmd" { + t.Errorf("windows: name=%q want cmd", name) + } + default: + if name != "xdg-open" { + t.Errorf("unix: name=%q want xdg-open", name) + } + } +} + +func TestOpenBrowserDoesNotBlock(t *testing.T) { + // OpenBrowser must return promptly regardless of whether a browser is + // actually available on the host (CI runners usually have none). + done := make(chan error, 1) + go func() { done <- OpenBrowser("about:blank") }() + select { + case err := <-done: + // Both nil and non-nil are acceptable; we only require non-blocking. + _ = err + case <-time.After(3 * time.Second): + t.Fatal("OpenBrowser blocked for >3s") + } +} diff --git a/internal/web/url_builder.go b/internal/web/url_builder.go new file mode 100644 index 0000000..7fcb8f5 --- /dev/null +++ b/internal/web/url_builder.go @@ -0,0 +1,203 @@ +// Package web constructs GitLink web page URLs and opens browsers. +// +// It is the single source of truth for the "terminal ↔ web" mapping used by +// the `show`, `browse`, and `--web` features. Every CLI command that needs a +// GitLink web URL must go through Builder instead of hand-rolling string +// concatenation, so the URL pattern table lives in exactly one place. +package web + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultBaseURL is the canonical GitLink front-end host. +const DefaultBaseURL = "https://gitlink.org.cn" + +// ResourceURL represents a resolvable GitLink web resource. +// +// The JSON tags deliberately match the keys the demo script and AI Agents +// expect (html_url / resource / show_command), so a Builder result can be +// wrapped straight into an output envelope. +type ResourceURL struct { + URL string `json:"html_url"` + Resource string `json:"resource"` + Identifier string `json:"identifier,omitempty"` + CLICommand string `json:"show_command,omitempty"` +} + +// Builder turns (resource type, identifiers) into GitLink web URLs. +// +// Builder never resolves owner/repo itself — callers pass them in, normally +// obtained from internal/context.ResolveOwnerRepo. This keeps Builder a pure +// formatter with no filesystem or git dependency. +type Builder struct { + BaseURL string +} + +// NewBuilder returns a Builder pointed at DefaultBaseURL. +func NewBuilder() *Builder { return &Builder{BaseURL: DefaultBaseURL} } + +// join concatenates the base URL with a path, tolerating missing/extra slashes. +func (b *Builder) join(path string) string { + base := strings.TrimRight(b.BaseURL, "/") + if path == "" { + return base + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return base + path +} + +// make assembles a ResourceURL from its parts. +func (b *Builder) make(resource, path, identifier, cli string) *ResourceURL { + return &ResourceURL{ + URL: b.join(path), + Resource: resource, + Identifier: identifier, + CLICommand: cli, + } +} + +// repoBase returns the "/owner/repo" prefix shared by most URLs. +func repoBase(owner, repo string) string { return fmt.Sprintf("/%s/%s", owner, repo) } + +// --- Repository-scoped resources ------------------------------------------- + +// RepoURL is the repository home page. +func (b *Builder) RepoURL(owner, repo string) *ResourceURL { + return b.make("repo", repoBase(owner, repo), "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s", owner, repo)) +} + +// IssueURL is a specific issue page (number > 0) or the issue list. +func (b *Builder) IssueURL(owner, repo string, number int) *ResourceURL { + if number <= 0 { + return b.make("issue", repoBase(owner, repo)+"/issues", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab issues", owner, repo)) + } + return b.make("issue", + fmt.Sprintf("%s/issues/%d", repoBase(owner, repo), number), + fmt.Sprintf("#%d", number), + fmt.Sprintf("gitlink-cli show issue --owner %s --repo %s --number %d", owner, repo, number)) +} + +// PRURL is a specific pull request page (number > 0) or the PR list. +func (b *Builder) PRURL(owner, repo string, number int) *ResourceURL { + if number <= 0 { + return b.make("pr", repoBase(owner, repo)+"/pulls", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab pulls", owner, repo)) + } + return b.make("pr", + fmt.Sprintf("%s/pulls/%d", repoBase(owner, repo), number), + fmt.Sprintf("#%d", number), + fmt.Sprintf("gitlink-cli show pr --owner %s --repo %s --number %d", owner, repo, number)) +} + +// WikiURL is a specific wiki page (page != "") or the wiki index. +func (b *Builder) WikiURL(owner, repo, page string) *ResourceURL { + if page == "" { + return b.make("wiki", repoBase(owner, repo)+"/wiki", "", + fmt.Sprintf("gitlink-cli show wiki --owner %s --repo %s", owner, repo)) + } + encoded := url.PathEscape(page) + return b.make("wiki", + fmt.Sprintf("%s/wiki/%s", repoBase(owner, repo), encoded), + page, + fmt.Sprintf("gitlink-cli show wiki --owner %s --repo %s --page %s", owner, repo, page)) +} + +// MemberURL is the repository collaborator settings page. +func (b *Builder) MemberURL(owner, repo string) *ResourceURL { + return b.make("member", repoBase(owner, repo)+"/settings/collaboration", "", + fmt.Sprintf("gitlink-cli show member --owner %s --repo %s", owner, repo)) +} + +// WebhookURL is the repository webhook settings page. +func (b *Builder) WebhookURL(owner, repo string) *ResourceURL { + return b.make("webhook", repoBase(owner, repo)+"/settings/hooks", "", + fmt.Sprintf("gitlink-cli show webhook --owner %s --repo %s", owner, repo)) +} + +// LabelURL is the repository issue-labels management page. +func (b *Builder) LabelURL(owner, repo string) *ResourceURL { + return b.make("label", repoBase(owner, repo)+"/issues/labels", "", + fmt.Sprintf("gitlink-cli show label --owner %s --repo %s", owner, repo)) +} + +// MilestoneURL is the repository milestones page. +func (b *Builder) MilestoneURL(owner, repo string) *ResourceURL { + return b.make("milestone", repoBase(owner, repo)+"/milestones", "", + fmt.Sprintf("gitlink-cli show milestone --owner %s --repo %s", owner, repo)) +} + +// BranchURL is the branches list, or a specific branch page when branch != "". +func (b *Builder) BranchURL(owner, repo, branch string) *ResourceURL { + if branch == "" { + return b.make("branch", repoBase(owner, repo)+"/branches", "", + fmt.Sprintf("gitlink-cli show branch --owner %s --repo %s", owner, repo)) + } + return b.make("branch", + fmt.Sprintf("%s/branches/%s", repoBase(owner, repo), url.PathEscape(branch)), + branch, + fmt.Sprintf("gitlink-cli show branch --owner %s --repo %s --branch %s", owner, repo, branch)) +} + +// ReleaseURL is the releases list, or a specific release when tag != "". +func (b *Builder) ReleaseURL(owner, repo, tag string) *ResourceURL { + if tag == "" { + return b.make("release", repoBase(owner, repo)+"/releases", "", + fmt.Sprintf("gitlink-cli show release --owner %s --repo %s", owner, repo)) + } + return b.make("release", + fmt.Sprintf("%s/releases/%s", repoBase(owner, repo), url.PathEscape(tag)), + tag, + fmt.Sprintf("gitlink-cli show release --owner %s --repo %s --tag %s", owner, repo, tag)) +} + +// CommitURL is a specific commit page. +func (b *Builder) CommitURL(owner, repo, sha string) *ResourceURL { + if sha == "" { + return b.make("commit", repoBase(owner, repo)+"/commits", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab commits", owner, repo)) + } + return b.make("commit", + fmt.Sprintf("%s/commits/%s", repoBase(owner, repo), url.PathEscape(sha)), + sha, + fmt.Sprintf("gitlink-cli show commit --owner %s --repo %s --sha %s", owner, repo, sha)) +} + +// CIURL is the repository CI/Actions page. +func (b *Builder) CIURL(owner, repo string) *ResourceURL { + return b.make("ci", repoBase(owner, repo)+"/actions", "", + fmt.Sprintf("gitlink-cli show ci --owner %s --repo %s", owner, repo)) +} + +// CompareURL is the branch/tag/commit comparison page. +func (b *Builder) CompareURL(owner, repo, base, head string) *ResourceURL { + path := fmt.Sprintf("%s/compare/%s...%s", repoBase(owner, repo), url.PathEscape(base), url.PathEscape(head)) + return b.make("compare", path, + fmt.Sprintf("%s...%s", base, head), + fmt.Sprintf("gitlink-cli show compare --owner %s --repo %s --base %s --head %s", owner, repo, base, head)) +} + +// --- Global resources (no owner/repo) -------------------------------------- + +// NotificationURL is the platform notification center. +func (b *Builder) NotificationURL() *ResourceURL { + return b.make("notification", "/notifications", "", "gitlink-cli show notification") +} + +// OrgURL is an organization page. +func (b *Builder) OrgURL(org string) *ResourceURL { + return b.make("org", fmt.Sprintf("/%s", org), org, + fmt.Sprintf("gitlink-cli show org --name %s", org)) +} + +// UserURL is a user profile page. +func (b *Builder) UserURL(login string) *ResourceURL { + return b.make("user", fmt.Sprintf("/%s", login), login, + fmt.Sprintf("gitlink-cli show user --login %s", login)) +} diff --git a/internal/web/url_builder_test.go b/internal/web/url_builder_test.go new file mode 100644 index 0000000..8d20b36 --- /dev/null +++ b/internal/web/url_builder_test.go @@ -0,0 +1,251 @@ +package web + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestNewBuilderDefaultBaseURL(t *testing.T) { + b := NewBuilder() + if b.BaseURL != DefaultBaseURL { + t.Fatalf("BaseURL = %q, want %q", b.BaseURL, DefaultBaseURL) + } +} + +func TestJoinTrimsTrailingSlash(t *testing.T) { + b := &Builder{BaseURL: "https://example.com///"} + got := b.join("/x") + want := "https://example.com/x" + if got != want { + t.Fatalf("join = %q, want %q", got, want) + } +} + +func TestJoinAddsLeadingSlash(t *testing.T) { + b := &Builder{BaseURL: "https://example.com"} + got := b.join("y") + want := "https://example.com/y" + if got != want { + t.Fatalf("join = %q, want %q", got, want) + } +} + +func TestJoinEmptyPath(t *testing.T) { + b := &Builder{BaseURL: "https://example.com/"} + if got := b.join(""); got != "https://example.com" { + t.Fatalf("join(\"\") = %q", got) + } +} + +// urlCase captures one Builder scenario for table-driven testing. +type urlCase struct { + name string + target *ResourceURL + wantURL string + wantRes string + wantID string + wantCLI string + wantSubs []string // substrings the CLI command must contain +} + +func runURLCases(t *testing.T, cases []urlCase) { + t.Helper() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.target.URL != c.wantURL { + t.Errorf("URL = %q, want %q", c.target.URL, c.wantURL) + } + if c.target.Resource != c.wantRes { + t.Errorf("Resource = %q, want %q", c.target.Resource, c.wantRes) + } + if c.wantID != "" && c.target.Identifier != c.wantID { + t.Errorf("Identifier = %q, want %q", c.target.Identifier, c.wantID) + } + if c.wantCLI != "" && c.target.CLICommand != c.wantCLI { + t.Errorf("CLICommand = %q, want %q", c.target.CLICommand, c.wantCLI) + } + for _, sub := range c.wantSubs { + if !strings.Contains(c.target.CLICommand, sub) { + t.Errorf("CLICommand %q missing substring %q", c.target.CLICommand, sub) + } + } + }) + } +} + +func TestBuilderRepoScopedURLs(t *testing.T) { + b := NewBuilder() + cases := []urlCase{ + { + name: "repo", + target: b.RepoURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo", + wantRes: "repo", + }, + { + name: "issue with number", + target: b.IssueURL("jiangtx", "gitlink-cli-demo", 42), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42", + wantRes: "issue", wantID: "#42", + }, + { + name: "issue list (number<=0)", + target: b.IssueURL("jiangtx", "gitlink-cli-demo", 0), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues", + wantRes: "issue", + }, + { + name: "pr with number", + target: b.PRURL("jiangtx", "gitlink-cli-demo", 128), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls/128", + wantRes: "pr", wantID: "#128", + }, + { + name: "pr list (number<=0)", + target: b.PRURL("jiangtx", "gitlink-cli-demo", 0), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls", + wantRes: "pr", + }, + { + name: "wiki page with spaces escaped", + target: b.WikiURL("jiangtx", "gitlink-cli-demo", "API 使用指南"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97", + wantRes: "wiki", wantID: "API 使用指南", + }, + { + name: "wiki index (empty page)", + target: b.WikiURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki", + wantRes: "wiki", + }, + { + name: "member", + target: b.MemberURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration", + wantRes: "member", + }, + { + name: "webhook", + target: b.WebhookURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks", + wantRes: "webhook", + }, + { + name: "label", + target: b.LabelURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/labels", + wantRes: "label", + }, + { + name: "milestone", + target: b.MilestoneURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/milestones", + wantRes: "milestone", + }, + { + name: "branch specific", + target: b.BranchURL("jiangtx", "gitlink-cli-demo", "feat/x"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/branches/feat%2Fx", + wantRes: "branch", wantID: "feat/x", + }, + { + name: "branch list", + target: b.BranchURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/branches", + wantRes: "branch", + }, + { + name: "release specific", + target: b.ReleaseURL("jiangtx", "gitlink-cli-demo", "v2.0"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/releases/v2.0", + wantRes: "release", wantID: "v2.0", + }, + { + name: "release list (empty tag)", + target: b.ReleaseURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/releases", + wantRes: "release", + }, + { + name: "commit specific", + target: b.CommitURL("jiangtx", "gitlink-cli-demo", "abc1234"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/commits/abc1234", + wantRes: "commit", wantID: "abc1234", + }, + { + name: "commit list (empty sha)", + target: b.CommitURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/commits", + wantRes: "commit", + }, + { + name: "ci", + target: b.CIURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/actions", + wantRes: "ci", + }, + { + name: "compare", + target: b.CompareURL("jiangtx", "gitlink-cli-demo", "master", "feat/x"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/compare/master...feat%2Fx", + wantRes: "compare", wantID: "master...feat/x", + }, + } + runURLCases(t, cases) +} + +func TestBuilderGlobalURLs(t *testing.T) { + b := NewBuilder() + cases := []urlCase{ + { + name: "notification", + target: b.NotificationURL(), + wantURL: "https://gitlink.org.cn/notifications", + wantRes: "notification", + }, + { + name: "org", + target: b.OrgURL("ccf"), + wantURL: "https://gitlink.org.cn/ccf", + wantRes: "org", wantID: "ccf", + }, + { + name: "user", + target: b.UserURL("jiangtx"), + wantURL: "https://gitlink.org.cn/jiangtx", + wantRes: "user", wantID: "jiangtx", + }, + } + runURLCases(t, cases) +} + +func TestCLICommandsContainFlags(t *testing.T) { + b := NewBuilder() + // Sanity: the show_command strings carry the flags the show subcommands parse. + if got := b.IssueURL("o", "r", 7).CLICommand; !strings.Contains(got, "--number 7") { + t.Errorf("issue CLI missing --number: %q", got) + } + if got := b.WikiURL("o", "r", "P").CLICommand; !strings.Contains(got, "--page P") { + t.Errorf("wiki CLI missing --page: %q", got) + } + if got := b.BranchURL("o", "r", "main").CLICommand; !strings.Contains(got, "--branch main") { + t.Errorf("branch CLI missing --branch: %q", got) + } +} + +func TestResourceURLJSONTags(t *testing.T) { + // Guard against accidental rename of the JSON keys the demo script and + // AI Agents depend on (html_url / resource / show_command). + r := ResourceURL{URL: "u", Resource: "issue", CLICommand: "c"} + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out := string(data) + for _, key := range []string{`"html_url":"u"`, `"resource":"issue"`, `"show_command":"c"`} { + if !strings.Contains(out, key) { + t.Errorf("JSON %q missing key %s", out, key) + } + } +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..ca2ab2d --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# ============================================================================= +# GitLink-CLI 子任务一 全量验证脚本(对齐 doc/DEMO-SCRIPT-SUBTASK1.md) +# +# 七段验证(每段独立,可单独跑:./verify.sh <段号>,如 ./verify.sh 5): +# 1 编译 + 静态检查 +# 2 单元测试 + 覆盖率(≥80%) +# 3 命令注册冒烟(show / demo / 8 个新 shortcut 模块) +# 4 能力探测(需 GITLINK_TOKEN;无则跳过) +# 5 终端↔网页联动(Demo Mock:show + html_url + demo +run --list) +# 6 五大类功能 E2E(Demo Mock + 真机可选) +# 7 format 统一 + --debug 增强(Demo Mock) +# +# 退出码:0 全绿;非 0 表示有失败段。 +# ============================================================================= +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Windows 默认二进制是 .exe;Linux/macOS 无后缀。 +if [ -x "$ROOT/gitlink-cli.exe" ]; then BIN="$ROOT/gitlink-cli.exe" +elif [ -x "$ROOT/gitlink-cli" ]; then BIN="$ROOT/gitlink-cli" +else BIN="" # 将在段3前构建 +fi + +PASS=0; FAIL=0; SKIP=0 +GREEN='\033[32m'; RED='\033[31m'; YEL='\033[1;33m'; BOLD='\033[1m'; RST='\033[0m' + +section(){ printf "\n${BOLD}━━━ 段 %s : %s ━━━${RST}\n" "$1" "$2"; } +ok() { printf " ${GREEN}✓${RST} %s\n" "$1"; PASS=$((PASS+1)); } +ng() { printf " ${RED}✗${RST} %s\n" "$1"; FAIL=$((FAIL+1)); } +skip() { printf " ${YEL}⊘${RST} %s\n" "$1"; SKIP=$((SKIP+1)); } +contain() { grep -q "$1" <<<"$2"; } + +# 选段:若传了段号,只跑该段。 +SELECTED="${1:-all}" +run_section(){ [ "$SELECTED" = "all" ] || [ "$SELECTED" = "$1" ]; } + +# ---------------------------------------------------------------------------- +section 1 "编译 + 静态检查" +if run_section 1; then + if go build -o "$ROOT/.gitlink-cli.verify" . 2>err.log; then + BIN="$ROOT/.gitlink-cli.verify"; ok "go build" + else ng "go build: $(cat err.log)"; fi + if go vet ./... >err.log 2>&1; then ok "go vet"; else ng "go vet: $(cat err.log)"; fi + if [ -z "$(gofmt -s -l . 2>/dev/null | grep -v '^vendor/')" ]; then ok "gofmt -s" + else ng "gofmt -s: $(gofmt -s -l . | head -3)"; fi + if go run ./internal/i18n/cmd/check >err.log 2>&1; then ok "i18n check" + else skip "i18n check (容许 P2 前未全覆盖)"; fi +fi + +# ---------------------------------------------------------------------------- +section 2 "单元测试 + 覆盖率(≥80%)" +if run_section 2; then + # -race 留给 CI 的 Linux runner;Windows 本地 race runtime 的 DLL 依赖不稳。 + RACE_FLAG="" + [ "$(go env GOOS)" != "windows" ] && RACE_FLAG="-race" + if go test $RACE_FLAG -coverprofile=coverage.verify.out ./... >test.log 2>&1; then + ok "go test ${RACE_FLAG:-(no-race)} 全绿" + else ng "go test 失败(见 test.log)"; tail -5 test.log; fi + COV=$(go tool cover -func=coverage.verify.out 2>/dev/null | grep '^total:' | awk '{print $3}' | tr -d '%') + COVI=${COV%.*} + if [ -n "$COVI" ] && [ "$COVI" -ge 80 ]; then ok "覆盖率 ${COV}% ≥ 80%" + else ng "覆盖率 ${COV:-?}% < 80%"; fi +fi + +# ---------------------------------------------------------------------------- +section 3 "命令注册冒烟" +if run_section 3; then + [ -z "$BIN" ] && { ng "无二进制可执行(先 go build)"; } + if [ -n "$BIN" ]; then + HELP=$("$BIN" --help 2>&1 || true) + for c in show demo browse status alias auth config api version; do + contain "$c" "$HELP" && ok "$c 注册" || ng "$c 未注册" + done + # 8 个新 shortcut 模块(第一类) + for grp in webhook wiki pm pipeline label member milestone notification; do + "$BIN" "$grp" --help >/dev/null 2>&1 && ok "$grp +help" || ng "$grp +help" + done + fi +fi + +# ---------------------------------------------------------------------------- +section 4 "能力探测(需 GITLINK_TOKEN)" +if run_section 4; then + if [ -z "${GITLINK_TOKEN:-}" ]; then + skip "段4 跳过(未设置 GITLINK_TOKEN)" + elif [ -z "$BIN" ]; then ng "无二进制" + else + OUT=$("$BIN" capability +check 2>&1 || true) + contain "label" "$OUT" && ok "capability +check 探测到模块" || ng "capability +check 无输出" + fi +fi + +# ---------------------------------------------------------------------------- +section 5 "终端↔网页联动(Demo Mock)" +if run_section 5 && [ -n "$BIN" ]; then + # show repo + OUT=$("$BIN" show repo --owner jiangtx --repo gitlink-cli-demo 2>&1) + contain "gitlink.org.cn/jiangtx/gitlink-cli-demo" "$OUT" && ok "show repo URL" || ng "show repo URL" + # show issue JSON html_url + OUT=$("$BIN" show issue --owner jiangtx --repo demo --number 42 --format json 2>&1) + contain '"html_url"' "$OUT" && ok "show issue JSON html_url" || ng "show issue html_url" + # show 各实体 + for ent in webhook wiki label milestone member ci; do + "$BIN" show "$ent" --owner o --repo r >/dev/null 2>&1 && ok "show $ent" || ng "show $ent" + done + # demo +run --list + OUT=$("$BIN" demo +run --list 2>&1) + contain "showcase" "$OUT" && ok "demo +run --list" || ng "demo +run --list" + # browse --list / --no-open + OUT=$("$BIN" browse --owner o --repo r --list 2>&1) + contain "issues" "$OUT" && ok "browse --list" || ng "browse --list" + OUT=$("$BIN" browse --owner o --repo r --no-open issues/42 2>&1) + contain "/issues/42" "$OUT" && ok "browse --no-open issues/42" || ng "browse --no-open" +fi + +# ---------------------------------------------------------------------------- +section 6 "五大类功能 E2E(Demo Mock)" +if run_section 6 && [ -n "$BIN" ]; then + export GITLINK_DEMO=1 + # 第一类:新增模块的 list + for cmd in "webhook +list --owner o --repo r" "label +list --owner o --repo r" \ + "milestone +list --owner o --repo r" "member +list --owner o --repo r" \ + "notification +list"; do + OUT=$("$BIN" $cmd 2>&1 || true) + (contain '"ok": true' "$OUT" || contain '"status":0' "$OUT") \ + && ok "第一类: $cmd" || ng "第一类: $cmd → $OUT" + done + # 第二类:三种输出格式 + for fmt in json table yaml; do + OUT=$("$BIN" repo +list --format "$fmt" 2>&1 || true) + [ -n "$OUT" ] && ok "第二类: repo +list --format $fmt" || ng "第二类: --format $fmt" + done + # 第三类:批量 dry-run + OUT=$("$BIN" issue +batch-close --owner o --repo r --numbers 1,2,3 --dry-run 2>&1 || true) + (contain "DRY RUN" "$OUT" || contain '"ok": true' "$OUT") \ + && ok "第三类: issue +batch-close --dry-run" || ng "第三类: batch-close → $OUT" + # 第五类:Raw API 补全 + OUT=$("$BIN" repo +languages --owner o --repo r 2>&1 || true) + (contain '"ok": true' "$OUT" || contain "Go" "$OUT") \ + && ok "第五类: repo +languages" || ng "第五类: repo +languages → $OUT" + unset GITLINK_DEMO +fi + +# ---------------------------------------------------------------------------- +section 7 "format 统一 + --debug 增强" +if run_section 7 && [ -n "$BIN" ]; then + export GITLINK_DEMO=1 + OUT=$("$BIN" alias +list --format json 2>&1 || true) + contain '"ok": true' "$OUT" && ok "alias --format json 走 output" || skip "alias format(P1-2 前可能未统一)" + OUT=$("$BIN" issue +list --owner o --repo r --debug 2>&1 || true) + (contain "→ GET" "$OUT" || contain "→ POST" "$OUT") \ + && ok "--debug 打印请求行" || ng "--debug 请求行" + contain "Bearer \*\*\*" "$OUT" && ok "--debug Authorization redact" \ + || skip "--debug Header redact(P2-2 前未实现)" + unset GITLINK_DEMO +fi + +# ---------------------------------------------------------------------------- +printf "\n${BOLD}━━━ 汇总 ━━━${RST}\n" +printf " ${GREEN}通过: %d${RST} ${RED}失败: %d${RST} ${YEL}跳过: %d${RST}\n" "$PASS" "$FAIL" "$SKIP" +rm -f "$ROOT/.gitlink-cli.verify" err.log test.log 2>/dev/null +[ "$FAIL" -eq 0 ] diff --git a/shortcuts/capability/capability.go b/shortcuts/capability/capability.go new file mode 100644 index 0000000..5c8e13f --- /dev/null +++ b/shortcuts/capability/capability.go @@ -0,0 +1,152 @@ +package capability + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/capability" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// SharedRegistry is the global capability registry used across the CLI. +// It is initialized in register.go and used by help annotations. +var SharedRegistry = capability.NewRegistry() + +// resultRow is one row of the capability table, also the JSON shape produced +// when --format json is passed. +type resultRow struct { + Domain string `json:"domain"` + Status string `json:"status"` + StatusText string `json:"status_text"` + Message string `json:"message,omitempty"` +} + +// buildResultRows turns the probe results into the ordered, structured form +// used by both the human-readable table and the structured envelope. +func buildResultRows(results map[string]*capability.DomainStatus) []resultRow { + domains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + rows := make([]resultRow, 0, len(domains)) + for _, d := range domains { + ds, ok := results[d] + if !ok || ds == nil { + rows = append(rows, resultRow{Domain: d, Status: "unknown", StatusText: "skipped", Message: "缺少 owner/repo 上下文,未探测"}) + continue + } + detail := ds.Message + if detail == "" && ds.Status == capability.StatusAvailable { + detail = "API 正常响应" + } + rows = append(rows, resultRow{ + Domain: d, + Status: statusString(ds.Status), + StatusText: statusText(ds.Status), + Message: detail, + }) + } + return rows +} + +func statusString(s capability.Status) string { + switch s { + case capability.StatusAvailable: + return "available" + case capability.StatusUnavailable: + return "unavailable" + case capability.StatusError: + return "error" + default: + return "unknown" + } +} + +func statusText(s capability.Status) string { + switch s { + case capability.StatusAvailable: + return "可用 ✓" + case capability.StatusUnavailable: + return "不可用 ✗" + case capability.StatusError: + return "错误 ✗" + default: + return "未知 ?" + } +} + +func statusEmoji(s string) string { + switch s { + case "available": + return "✓" + case "unavailable", "error": + return "✗" + default: + return "?" + } +} + +// ensure output stays referenced for future structured extensions. +var _ = output.SuccessEnvelope + +// Shortcuts returns the capability management shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "check", + Description: "探测后端 API 能力,检查各模块是否可用", + Long: `向 GitLink 后端发送探测请求,检查各命令模块依赖的 API 是否就绪。 + +探测结果会缓存 24 小时。之后运行 capability +list 查看缓存结果。 + +需要 owner/repo 上下文的模块(如 label、webhook、member 等)会自动从 +git remote 推断,或通过 --owner/--repo 指定。`, + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + // Resolve owner/repo for repo-dependent probes + owner, repo := ctx.Owner, ctx.Repo + if owner == "" || repo == "" { + _ = ctx.ResolveOwnerRepo() + owner, repo = ctx.Owner, ctx.Repo + } + + results := SharedRegistry.Refresh(ctx.Client, owner, repo) + + // When the user explicitly asks for a structured format, route + // through the output envelope (so capability +check plays nice + // with --format json/table/yaml and AI Agents). Empty format = + // the human-readable table (the historical default). + if cmdutil.Format != "" { + return ctx.OutputData(buildResultRows(results)) + } + + // Print results table + fmt.Println("API 后端能力探测结果:") + fmt.Println() + fmt.Printf(" %-4s %-15s %-12s %s\n", "", "模块", "状态", "说明") + fmt.Println(" " + "---- --------------- ------------ ------------------------------") + for _, row := range buildResultRows(results) { + icon := statusEmoji(row.Status) + fmt.Printf(" %-4s %-15s %-12s %s\n", icon, row.Domain, row.StatusText, row.Message) + } + fmt.Println() + fmt.Println("提示: 不可用的模块会在 --help 中标记 ⚠,调用时会显示中文错误指引。") + fmt.Println("缓存位置: ~/.config/gitlink-cli/capabilities.json(24 小时有效)") + return nil + }, + }, + { + Name: "list", + Description: "查看已缓存的 API 能力探测结果", + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + fmt.Print(SharedRegistry.Summary()) + if SharedRegistry.IsStale() { + fmt.Println("\n⚠ 缓存已过期(超过 24 小时),运行 capability +check 刷新。") + } + return nil + }, + }, + } +} diff --git a/shortcuts/capability/capability_test.go b/shortcuts/capability/capability_test.go new file mode 100644 index 0000000..2b63acc --- /dev/null +++ b/shortcuts/capability/capability_test.go @@ -0,0 +1,77 @@ +package capability + +import ( + "testing" + + intcap "github.com/gitlink-org/gitlink-cli/internal/capability" +) + +func TestBuildResultRowsOrderAndContent(t *testing.T) { + results := map[string]*intcap.DomainStatus{ + "label": {Status: intcap.StatusAvailable}, + "webhook": {Status: intcap.StatusUnavailable, Message: "返回 HTML"}, + "pipeline": {Status: intcap.StatusError, Message: "网络错误"}, + } + rows := buildResultRows(results) + // buildResultRows always emits the full fixed domain catalog (11 entries). + if len(rows) != 11 { + t.Fatalf("got %d rows, want 11", len(rows)) + } + wantFirst := "label" + if rows[0].Domain != wantFirst { + t.Errorf("first row = %q, want %q", rows[0].Domain, wantFirst) + } + byDomain := map[string]resultRow{} + for _, r := range rows { + byDomain[r.Domain] = r + } + if byDomain["label"].Status != "available" { + t.Errorf("label status = %q", byDomain["label"].Status) + } + if byDomain["webhook"].Status != "unavailable" || byDomain["webhook"].Message != "返回 HTML" { + t.Errorf("webhook row wrong: %+v", byDomain["webhook"]) + } +} + +func TestBuildResultRowsMissingDomainIsSkipped(t *testing.T) { + // An empty results map → every domain lands in the "skipped" branch. + rows := buildResultRows(map[string]*intcap.DomainStatus{}) + for _, r := range rows { + if r.Status != "unknown" { + t.Errorf("domain %s: expected unknown/skipped, got %s", r.Domain, r.Status) + } + } +} + +func TestBuildResultRowsNilEntryIsSkipped(t *testing.T) { + results := map[string]*intcap.DomainStatus{"label": nil} + rows := buildResultRows(results) + for _, r := range rows { + if r.Domain == "label" && r.Status != "unknown" { + t.Errorf("nil entry should be skipped, got %s", r.Status) + } + } +} + +func TestStatusHelpers(t *testing.T) { + cases := []struct { + status intcap.Status + str, text, ico string + }{ + {intcap.StatusAvailable, "available", "可用 ✓", "✓"}, + {intcap.StatusUnavailable, "unavailable", "不可用 ✗", "✗"}, + {intcap.StatusError, "error", "错误 ✗", "✗"}, + {intcap.StatusUnknown, "unknown", "未知 ?", "?"}, + } + for _, c := range cases { + if got := statusString(c.status); got != c.str { + t.Errorf("statusString(%v) = %q, want %q", c.status, got, c.str) + } + if got := statusText(c.status); got != c.text { + t.Errorf("statusText(%v) = %q, want %q", c.status, got, c.text) + } + if got := statusEmoji(c.str); got != c.ico { + t.Errorf("statusEmoji(%q) = %q, want %q", c.str, got, c.ico) + } + } +} diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index 0cd3b14..02de0d5 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -95,47 +95,47 @@ 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: "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: "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) - }, + }, + { + 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) }, }, } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 9bdc5bc..a29caa8 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -3,10 +3,16 @@ package common import ( "strconv" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) +// newRuntimeContextForTest is the indirection through which MountShortcut +// obtains a RuntimeContext. It defaults to NewRuntimeContext; tests swap it to +// avoid constructing a real *client.Client (which touches the token store). +var newRuntimeContextForTest = NewRuntimeContext + // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { tr := i18n.Default() @@ -32,7 +38,7 @@ func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Tran } } - ctx, err := NewRuntimeContext(flagValues, tr) + ctx, err := newRuntimeContextForTest(flagValues, tr) if err != nil { return err } @@ -43,7 +49,13 @@ func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Tran } } - return s.Run(ctx) + if err := s.Run(ctx); err != nil { + return err + } + if cmdutil.Web { + postWebHook(ctx, cmd) + } + return nil }, } diff --git a/shortcuts/common/runner_test.go b/shortcuts/common/runner_test.go index 9bbc4df..2042405 100644 --- a/shortcuts/common/runner_test.go +++ b/shortcuts/common/runner_test.go @@ -77,3 +77,72 @@ func TestMountShortcuts(t *testing.T) { t.Fatalf("called = %v, want [first second]", called) } } + +// TestMountShortcutRequiredFlagError exercises the Required-flag short-circuit +// path in MountShortcut's RunE (the early return before Run is invoked). +func TestMountShortcutRequiredFlagError(t *testing.T) { + ran := false + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "create", + Flags: []Flag{{Name: "title", Required: true}}, + Run: func(ctx *RuntimeContext) error { + ran = true + return nil + }, + }) + root.SetArgs([]string{"+create"}) // no --title + err := root.Execute() + if err == nil { + t.Fatal("expected error when required flag is missing") + } + if ran { + t.Error("Run must not execute when a required flag is missing") + } +} + +// TestMountShortcutBoolWithShortFlag covers the BoolP branch of flag mounting. +func TestMountShortcutBoolWithShortFlag(t *testing.T) { + var got string + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "toggle", + Flags: []Flag{ + {Name: "force", Short: "f", Bool: true, Default: "false"}, + }, + Run: func(ctx *RuntimeContext) error { + got = ctx.Arg("force") + return nil + }, + }) + root.SetArgs([]string{"+toggle", "-f"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got != "true" { + t.Errorf("force flag = %q, want true", got) + } +} + +// TestMountShortcutStringWithShortFlag covers the StringP branch. +func TestMountShortcutStringWithShortFlag(t *testing.T) { + var got string + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "named", + Flags: []Flag{ + {Name: "label", Short: "l", Default: ""}, + }, + Run: func(ctx *RuntimeContext) error { + got = ctx.Arg("label") + return nil + }, + }) + root.SetArgs([]string{"+named", "-l", "bug"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got != "bug" { + t.Errorf("label = %q, want bug", got) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052a..7436d6c 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "sort" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -40,6 +41,9 @@ type RuntimeContext struct { Format string Args map[string]string Tr *i18n.Translator + // LastEnvelope is the most recent envelope printed via Output/OutputData. + // The `--web` post-processor reads it to resolve a resource back to a URL. + LastEnvelope *output.Envelope } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. @@ -95,14 +99,18 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R return ctx.Client.PaginateAll(path, params) } -// Output prints the envelope in the configured format. +// Output prints the envelope in the configured format and caches it so the +// `--web` post-processor can resolve the resource to a web URL. func (ctx *RuntimeContext) Output(env *output.Envelope) error { + ctx.LastEnvelope = env return output.Print(env, ctx.Format) } // OutputData wraps data in a success envelope and prints it. func (ctx *RuntimeContext) OutputData(data interface{}) error { - return output.Print(output.SuccessEnvelope(data, nil), ctx.Format) + env := output.SuccessEnvelope(data, nil) + ctx.LastEnvelope = env + return output.Print(env, ctx.Format) } // RepoPath returns the API path prefix for the current owner/repo. @@ -110,6 +118,95 @@ func (ctx *RuntimeContext) RepoPath() string { return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo) } +// ResolveProjectID returns the project ID from --project-id flag if provided, +// otherwise fetches it automatically via the repo detail API (/{owner}/{repo}). +func (ctx *RuntimeContext) ResolveProjectID() (string, error) { + if id := ctx.Arg("project-id"); id != "" { + return id, nil + } + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return "", fmt.Errorf("无法自动获取项目 ID: %w\n请手动指定 --project-id", err) + } + if env.Data == nil { + return "", fmt.Errorf("仓库信息返回为空,请手动指定 --project-id") + } + m, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("仓库信息格式异常,请手动指定 --project-id") + } + + // Try multiple common locations for the project ID in GitLink API responses. + // The structure varies: sometimes id is at top level, sometimes nested. + if idStr := extractProjectID(m); idStr != "" { + return idStr, nil + } + + // Show available keys for diagnosis + keys := sortedKeys(m) + return "", fmt.Errorf("仓库信息中未找到 id 字段 (可用字段: %v)\n请手动指定 --project-id", keys) +} + +// extractProjectID tries common field locations for the project numeric ID. +func extractProjectID(m map[string]interface{}) string { + // 1. Top-level "id" + if v := floatID(m["id"]); v != "" { + return v + } + // 2. Top-level "project_id" or "repo_id" + if v := floatID(m["project_id"]); v != "" { + return v + } + if v := floatID(m["repo_id"]); v != "" { + return v + } + // 3. Nested under "data" key: data.id, data.project.id + if data, ok := m["data"].(map[string]interface{}); ok { + if v := floatID(data["id"]); v != "" { + return v + } + if v := floatID(data["project_id"]); v != "" { + return v + } + if proj, ok := data["project"].(map[string]interface{}); ok { + if v := floatID(proj["id"]); v != "" { + return v + } + } + } + // 4. Nested under "project" key + if proj, ok := m["project"].(map[string]interface{}); ok { + if v := floatID(proj["id"]); v != "" { + return v + } + } + return "" +} + +func floatID(v interface{}) string { + if v == nil { + return "" + } + switch n := v.(type) { + case float64: + return fmt.Sprintf("%.0f", n) + case string: + return n + case json.Number: + return n.String() + } + return "" +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // Arg returns a flag value, or the default if not set. func (ctx *RuntimeContext) Arg(name string) string { if v, ok := ctx.Args[name]; ok { diff --git a/shortcuts/common/web_post.go b/shortcuts/common/web_post.go new file mode 100644 index 0000000..e110db9 --- /dev/null +++ b/shortcuts/common/web_post.go @@ -0,0 +1,99 @@ +package common + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +// webStdout is the post-processor's output target (so tests can redirect). +var webStdout io.Writer = os.Stdout + +// openFunc opens a URL in the browser; tests stub it to avoid real exec. +var openFunc = web.OpenBrowser + +// postWebHook prints and opens the GitLink web URL for the resource a shortcut +// just operated on. It runs only when --web is set, after the shortcut's Run +// has already produced its normal output. +// +// The resource type is inferred from the parent cobra command name (e.g. for +// `issue +create` the parent is `issue`). Resources that carry an id/number +// in their response envelope (issue/pr) resolve to a detail page; the rest +// resolve to the relevant management page. +func postWebHook(ctx *RuntimeContext, cmd *cobra.Command) { + if cmd == nil || cmd.Parent() == nil { + return + } + resource := cmd.Parent().Name() + owner, repo := ctx.Owner, ctx.Repo + if owner == "" || repo == "" { + return // cannot build a repo-scoped URL + } + b := web.NewBuilder() + number := extractNumberFromEnvelope(ctx.LastEnvelope) + rurl, ok := resourceURL(b, resource, owner, repo, number) + if !ok { + return // unsupported resource: silently skip + } + fmt.Fprintf(webStdout, "\n🔗 网页链接: %s\n", rurl.URL) + _ = openFunc(rurl.URL) +} + +// resourceURL maps a (resource, owner, repo, number) tuple to a web URL, +// returning ok=false for resources that have no web representation. +func resourceURL(b *web.Builder, resource, owner, repo string, number int) (*web.ResourceURL, bool) { + switch resource { + case "issue": + return b.IssueURL(owner, repo, number), true + case "pr": + return b.PRURL(owner, repo, number), true + case "webhook": + return b.WebhookURL(owner, repo), true + case "label": + return b.LabelURL(owner, repo), true + case "milestone": + return b.MilestoneURL(owner, repo), true + case "wiki": + return b.WikiURL(owner, repo, ""), true + case "repo": + return b.RepoURL(owner, repo), true + case "branch": + return b.BranchURL(owner, repo, ""), true + case "release": + return b.ReleaseURL(owner, repo, ""), true + case "member": + return b.MemberURL(owner, repo), true + case "ci": + return b.CIURL(owner, repo), true + default: + return nil, false + } +} + +// extractNumberFromEnvelope tries common field names for an issue/PR number in +// a GitLink API response. Returns 0 (→ list page) when nothing matches. +func extractNumberFromEnvelope(env *output.Envelope) int { + if env == nil || env.Data == nil { + return 0 + } + m, ok := env.Data.(map[string]interface{}) + if !ok { + return 0 + } + for _, key := range []string{"number", "project_issues_index", "pull_request_id", "id"} { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + } + return 0 +} diff --git a/shortcuts/common/web_post_test.go b/shortcuts/common/web_post_test.go new file mode 100644 index 0000000..45e47e0 --- /dev/null +++ b/shortcuts/common/web_post_test.go @@ -0,0 +1,184 @@ +package common + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +func TestExtractNumberFromEnvelope(t *testing.T) { + cases := []struct { + name string + env *output.Envelope + want int + }{ + {"nil", nil, 0}, + {"nil data", &output.Envelope{OK: true, Data: nil}, 0}, + {"non-map data", &output.Envelope{OK: true, Data: "string"}, 0}, + {"number field", &output.Envelope{OK: true, Data: map[string]interface{}{"number": float64(42)}}, 42}, + {"project_issues_index field", &output.Envelope{OK: true, Data: map[string]interface{}{"project_issues_index": float64(99)}}, 99}, + {"id field fallback", &output.Envelope{OK: true, Data: map[string]interface{}{"id": float64(7)}}, 7}, + {"no matching field", &output.Envelope{OK: true, Data: map[string]interface{}{"foo": "bar"}}, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := extractNumberFromEnvelope(c.env); got != c.want { + t.Errorf("got %d, want %d", got, c.want) + } + }) + } +} + +func TestResourceURLMapping(t *testing.T) { + b := web.NewBuilder() + for _, res := range []string{"issue", "pr", "webhook", "label", "milestone", "wiki", "repo", "branch", "release", "member", "ci"} { + r, ok := resourceURL(b, res, "o", "r", 1) + if !ok { + t.Errorf("resource %q: expected ok", res) + continue + } + if r == nil || r.URL == "" { + t.Errorf("resource %q: empty URL", res) + } + } +} + +func TestResourceURLUnknown(t *testing.T) { + if _, ok := resourceURL(web.NewBuilder(), "unknown-res", "o", "r", 1); ok { + t.Error("unknown resource should return ok=false") + } +} + +func TestResourceURLIssueUsesNumber(t *testing.T) { + r, ok := resourceURL(web.NewBuilder(), "issue", "o", "r", 42) + if !ok { + t.Fatal("expected ok") + } + if !strings.HasSuffix(r.URL, "/issues/42") { + t.Errorf("expected /issues/42 suffix, got %q", r.URL) + } +} + +func TestPostWebHookPrintsURLForIssue(t *testing.T) { + parent := &cobra.Command{Use: "issue"} + child := &cobra.Command{Use: "+create"} + parent.AddCommand(child) + + ctx := &RuntimeContext{Owner: "jiangtx", Repo: "demo"} + capture, restore := redirectWeb() + defer restore() + stubOpen(t) + + postWebHook(ctx, child) + + if !strings.Contains(capture.String(), "/jiangtx/demo/issues") { + t.Errorf("expected issue URL, got %q", capture.String()) + } + if !strings.Contains(capture.String(), "🔗") { + t.Errorf("expected 🔗 marker, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsWhenParentMissing(t *testing.T) { + ctx := &RuntimeContext{Owner: "o", Repo: "r"} + child := &cobra.Command{Use: "+create"} // no parent + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) // must not panic or print + if capture.Len() != 0 { + t.Errorf("expected no output, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsWhenOwnerRepoMissing(t *testing.T) { + parent := &cobra.Command{Use: "issue"} + child := &cobra.Command{Use: "+create"} + parent.AddCommand(child) + ctx := &RuntimeContext{} // no owner/repo + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) + if capture.Len() != 0 { + t.Errorf("expected no output without owner/repo, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsUnsupportedResource(t *testing.T) { + parent := &cobra.Command{Use: "search"} // not in the switch + child := &cobra.Command{Use: "+repos"} + parent.AddCommand(child) + ctx := &RuntimeContext{Owner: "o", Repo: "r"} + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) + if capture.Len() != 0 { + t.Errorf("expected no output for unsupported resource, got %q", capture.String()) + } +} + +// redirectWeb swaps webStdout + openFunc for the duration of a test, +// returning the capture buffer and a restore func. +func redirectWeb() (*bytes.Buffer, func()) { + old := webStdout + oldOpen := openFunc + buf := &bytes.Buffer{} + webStdout = buf + openFunc = func(string) error { return nil } + return buf, func() { + webStdout = old + openFunc = oldOpen + } +} + +func stubOpen(t *testing.T) { + t.Helper() + openFunc = func(string) error { return nil } +} + +// TestMountShortcutTriggersWebPostProcess verifies that when --web is set, +// MountShortcut's RunE invokes postWebHook after the shortcut's Run completes. +func TestMountShortcutTriggersWebPostProcess(t *testing.T) { + // NewRuntimeContext normally constructs a real *client.Client (which reads + // config + token store). To keep this a pure wiring test, we swap the + // context factory for one that returns a hand-built context. + oldNew := newRuntimeContextForTest + ctx := &RuntimeContext{Owner: "o", Repo: "r", Format: "json"} + newRuntimeContextForTest = func(_ map[string]string, _ ...*i18n.Translator) (*RuntimeContext, error) { + return ctx, nil + } + defer func() { newRuntimeContextForTest = oldNew }() + + opened := false + oldOpen := openFunc + openFunc = func(string) error { opened = true; return nil } + defer func() { openFunc = oldOpen }() + + oldWeb := cmdutil.Web + cmdutil.Web = true + defer func() { cmdutil.Web = oldWeb }() + + parent := &cobra.Command{Use: "issue"} + sc := &Shortcut{ + Name: "create", + Run: func(c *RuntimeContext) error { + c.LastEnvelope = output.SuccessEnvelope( + map[string]interface{}{"number": float64(42)}, nil) + return nil + }, + } + MountShortcut(parent, sc) + parent.SetArgs([]string{"+create"}) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !opened { + t.Error("--web did not trigger postWebHook (browser not opened)") + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index bab0f69..3c5d6b0 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -363,76 +363,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: "journals", + Description: "查看 Issue 的活动日志(评论、状态变更等)", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue 编号(网页 URL 中的数字)", Required: true}, }, - { - 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) - }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := ctx.RequireArg("number") + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) }, + }, + { + Name: "series-update", + Description: "批量更新多个 Issue 的状态(一键关闭/重开多个 Issue)", + Flags: []common.Flag{ + {Name: "ids", Usage: "Issue ID 列表(逗号分隔,如 1,2,3)", Required: true}, + {Name: "status", Short: "s", Usage: "目标状态: open / closed", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + idsStr, err := ctx.RequireArg("ids") + if err != nil { + return err + } + status, err := ctx.RequireArg("status") + if err != nil { + return err + } + + // 解析逗号分隔的 ID 列表 + idParts := strings.Split(idsStr, ",") + ids := make([]int, 0, len(idParts)) + for _, p := range idParts { + id, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return fmt.Errorf("无效的 Issue ID: %s", p) + } + ids = append(ids, id) + } + + // 转换状态为数字 + statusID, err := normalizeIssueStatus(status) + if err != nil { + return err + } + + body := map[string]interface{}{ + "ids": ids, + "status_id": statusID, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/issues/series_update", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 2b6a298..6dd847e 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -91,6 +91,16 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "clone", + Description: "Clone labels from another repository", + Flags: []common.Flag{ + {Name: "source-owner", Short: "o", Usage: "Source repository owner", Required: true}, + {Name: "source-repo", Short: "r", Usage: "Source repository name", Required: true}, + {Name: "overwrite", Usage: "Overwrite existing labels with same name (true/false)", Default: "false"}, + }, + Run: runClone, + }, } } @@ -242,3 +252,93 @@ func firstNonEmpty(values ...string) string { } return "" } + +func runClone(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sourceOwner, err := ctx.RequireArg("source-owner") + if err != nil { + return err + } + sourceRepo, err := ctx.RequireArg("source-repo") + if err != nil { + return err + } + + sourcePath := fmt.Sprintf("/v1/%s/%s/issue_tags", sourceOwner, sourceRepo) + env, err := ctx.CallAPI("GET", sourcePath, nil) + if err != nil { + return fmt.Errorf("failed to fetch source labels: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response format from source repository") + } + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + return fmt.Errorf("no issue_tags found in source repository") + } + + overwrite := ctx.Arg("overwrite") == "true" + created := 0 + skipped := 0 + + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name := stringFromMap(tag, "name") + if name == "" { + continue + } + if !overwrite { + if existing, _ := fetchLabelByName(ctx, name); existing != nil { + skipped++ + continue + } + } + color := stringFromMap(tag, "color") + if color == "" { + color = defaultLabelColor + } + payload := map[string]interface{}{ + "name": name, + "description": stringFromMap(tag, "description"), + "color": color, + } + if _, err := ctx.CallAPI("POST", labelPath(ctx), payload); err != nil { + return fmt.Errorf("failed to create label %q: %w", name, err) + } + created++ + } + + fmt.Printf("Cloned %d labels from %s/%s (skipped %d existing)\n", created, sourceOwner, sourceRepo, skipped) + return nil +} + +func fetchLabelByName(ctx *common.RuntimeContext, name string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", labelPath(ctx), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, nil + } + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + return nil, nil + } + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if stringFromMap(tag, "name") == name { + return tag, nil + } + } + return nil, nil +} diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 3734da3..d8cf3b7 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -1,16 +1,17 @@ -package member +package member //声明自己是属于哪个包 import ( - "encoding/csv" - "fmt" - "net/url" + "encoding/csv" // 处理 CSV 文件 + "fmt" // 格式化输出和错误信息 + "net/url" // URL 参数编码 "os" - "strconv" + "strconv" // 字符串和数字的互转 "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// 角色别名映射,即大小写都能识别 var roleAliases = map[string]string{ "manager": "Manager", "developer": "Developer", @@ -21,24 +22,29 @@ var roleAliases = map[string]string{ } // Shortcuts returns repository member management shortcuts. +// 返回快捷命令的定义 func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { - Name: "list", - Description: "List repository members", + Name: "list", //子命令名,列出所有成员 + Description: "List repository members", //对于子命令的描述 + //Run:实际执行的函数 Run: func(ctx *common.RuntimeContext) error { + //ResolveOwnerRepo:解析 owner/repo,确定是哪个仓库 if err := ctx.ResolveOwnerRepo(); err != nil { return err } + //调用后端 API env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) if err != nil { return err } + //输出结果给用户 return ctx.Output(env) }, }, { - Name: "add", + Name: "add", //添加单个成员 Description: "Add a repository member by user ID", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, @@ -58,18 +64,20 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + //batch-add 快捷命令注册 { - Name: "batch-add", + Name: "batch-add", //批量添加 Description: "Add multiple repository members by user IDs or a CSV file", Flags: []common.Flag{ {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + //预览模式 {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, }, Run: runBatchAdd, }, { - Name: "remove", + Name: "remove", //移除成员 Description: "Remove a repository member by user ID", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, @@ -90,7 +98,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "role", + Name: "role", //修改角色 Description: "Change a repository member role", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, @@ -119,7 +127,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "invite-link", + Name: "invite-link", //获取/生成邀请链接 Description: "Get or create a repository invite link", Flags: []common.Flag{ {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, @@ -148,7 +156,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "invite-info", + Name: "invite-info", //查看邀请详情 Description: "Show repository invite link information", Flags: []common.Flag{ {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, @@ -171,7 +179,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "accept-invite", + Name: "accept-invite", //接受邀请 Description: "Accept a repository invite link", Flags: []common.Flag{ {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, @@ -196,10 +204,12 @@ func Shortcuts() []*common.Shortcut { } } +// 批量加入成员主函数 func runBatchAdd(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } + //1.解析参数,收集用户 ID userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) if err != nil { return err @@ -207,6 +217,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { if len(userIDs) == 0 { return fmt.Errorf("provide --user-ids or --from") } + //2.Dry-run 预览输出 if parseDryRun(ctx.Arg("dry-run")) { return ctx.OutputData(map[string]interface{}{ "dry_run": true, @@ -218,6 +229,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { results := make([]map[string]interface{}, 0, len(userIDs)) succeeded := 0 failed := 0 + //逐个调用 API 添加 for _, userID := range userIDs { env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) result := map[string]interface{}{"user_id": userID} @@ -251,6 +263,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { } func collaboratorsPath(ctx *common.RuntimeContext) string { + //printf return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) } @@ -275,14 +288,17 @@ func parseUserID(value string) (int, error) { return userID, nil } +// 角色规范化 +// 返回大写形式 func normalizeRole(value string) (string, error) { - role, ok := roleAliases[strings.TrimSpace(value)] + role, ok := roleAliases[strings.TrimSpace(value)] //从映射表中查找 if !ok { return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) } return role, nil } +// 返回小写形式 func normalizeInviteRole(value string) (string, error) { role, err := normalizeRole(value) if err != nil { @@ -307,8 +323,9 @@ func parseDryRun(value string) bool { return ok && strings.TrimSpace(value) != "" } +// 从 --user-ids 参数和 --from CSV 文件合并收集用户ID,自动去重 func collectUserIDs(inline, csvPath string) ([]int, error) { - seen := map[int]bool{} + seen := map[int]bool{} //记录已经见过的用户ID var ids []int add := func(raw string) error { if strings.TrimSpace(raw) == "" { @@ -345,6 +362,7 @@ func collectUserIDs(inline, csvPath string) ([]int, error) { return ids, nil } +// 读取 CSV 文件,自动识别 user_id/userid/id 列头,无表头时读取第一列 func readUserIDsFromCSV(path string) ([]int, error) { file, err := os.Open(path) if err != nil { diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go index 485c73d..ac58be8 100644 --- a/shortcuts/notification/notification.go +++ b/shortcuts/notification/notification.go @@ -64,5 +64,34 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "watch", + Description: "关注或取消关注仓库的通知", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "仓库所有者", Required: true}, + {Name: "repo", Short: "r", Usage: "仓库名称", Required: true}, + {Name: "unwatch", Usage: "取消关注(默认为关注)", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + owner, err := ctx.RequireArg("owner") + if err != nil { + return err + } + repo, err := ctx.RequireArg("repo") + if err != nil { + return err + } + path := fmt.Sprintf("/watchers/%s/%s.json", owner, repo) + method := "POST" + if ctx.Arg("unwatch") == "true" { + method = "DELETE" + } + env, err := ctx.CallAPI(method, path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/notification/notification_test.go b/shortcuts/notification/notification_test.go new file mode 100644 index 0000000..034b28f --- /dev/null +++ b/shortcuts/notification/notification_test.go @@ -0,0 +1,189 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runNotifShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + s := findNotifShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func findNotifShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeNotifJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// --- list --- + +func TestNotifListBasic(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 != "/notifications.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("got page %q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Fatalf("got limit %q, want %q", got, "20") + } + writeNotifJSON(w, map[string]interface{}{ + "total_count": float64(1), + "notifications": []interface{}{ + map[string]interface{}{"id": float64(1), "unread": true}, + }, + }) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "list", map[string]string{ + "page": "1", "limit": "20", "all": "false", "participating": "false", + }) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestNotifListWithAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("all"); got != "true" { + t.Fatalf("expected all=true, got %q", got) + } + writeNotifJSON(w, map[string]interface{}{"total_count": float64(0), "notifications": []interface{}{}}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "list", map[string]string{ + "page": "1", "limit": "20", "all": "true", "participating": "false", + }) + if err != nil { + t.Fatalf("list with all failed: %v", err) + } +} + +func TestNotifListWithParticipating(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("participating"); got != "true" { + t.Fatalf("expected participating=true, got %q", got) + } + writeNotifJSON(w, map[string]interface{}{"total_count": float64(0), "notifications": []interface{}{}}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "list", map[string]string{ + "page": "1", "limit": "20", "all": "false", "participating": "true", + }) + if err != nil { + t.Fatalf("list with participating failed: %v", err) + } +} + +// --- read --- + +func TestNotifRead(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/notifications/42.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "read", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("read failed: %v", err) + } +} + +// --- read-all --- + +func TestNotifReadAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/notifications.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "read-all", map[string]string{}) + if err != nil { + t.Fatalf("read-all failed: %v", err) + } +} + +// --- watch --- + +func TestNotifWatch(t *testing.T) { + 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 != "/watchers/alice/repo.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "watch", map[string]string{ + "owner": "alice", "repo": "repo", "unwatch": "false", + }) + if err != nil { + t.Fatalf("watch failed: %v", err) + } +} + +func TestNotifUnwatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/watchers/bob/project.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runNotifShortcut(t, server, "watch", map[string]string{ + "owner": "bob", "repo": "project", "unwatch": "true", + }) + if err != nil { + t.Fatalf("unwatch failed: %v", err) + } +} diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 1003b6b..fde66ba 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -85,71 +85,71 @@ 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: "teams", + Description: "列出组织下的所有团队", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, }, - { - 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) - }, + 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: "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: "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) }, }, } diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 330c4f9..300bce1 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "os/exec" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -446,6 +447,71 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "checkout", + Description: "Checkout a pull request locally (fetch + checkout)", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "remote", Short: "r", Usage: "Git remote name", Default: "origin"}, + {Name: "branch", Short: "b", Usage: "Custom local branch name (defaults to PR head branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + remote := ctx.Arg("remote") + if remote == "" { + remote = "origin" + } + + // Fetch PR details to get the head branch name. + prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("fetch PR info: %w", err) + } + headBranch, err := extractHeadBranch(prEnv) + if err != nil { + return err + } + + localBranch := ctx.Arg("branch") + if localBranch == "" { + localBranch = headBranch + } + + // git fetch + fetchCmd := exec.Command("git", "fetch", remote, headBranch) + if out, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch %s %s failed: %s", remote, headBranch, strings.TrimSpace(string(out))) + } + fmt.Printf("✓ Fetched %s from %s\n", headBranch, remote) + + // Try switching to an existing local branch first. + checkoutCmd := exec.Command("git", "checkout", localBranch) + checkoutOut, checkoutErr := checkoutCmd.CombinedOutput() + if checkoutErr != nil { + // Branch does not exist locally — create it tracking the remote. + trackRef := fmt.Sprintf("%s/%s", remote, headBranch) + createCmd := exec.Command("git", "checkout", "-b", localBranch, trackRef) + if createOut, createErr := createCmd.CombinedOutput(); createErr != nil { + return fmt.Errorf("git checkout failed:\n %s\n %s", + strings.TrimSpace(string(checkoutOut)), + strings.TrimSpace(string(createOut))) + } + fmt.Printf("✓ Created and checked out local branch %q (tracking %s)\n", localBranch, trackRef) + fmt.Printf(" PR #%s: %s/%s → %s\n", id, ctx.Owner, ctx.Repo, localBranch) + return nil + } + + fmt.Printf("✓ Switched to existing branch %q\n", localBranch) + fmt.Printf(" PR #%s: %s/%s → %s\n", id, ctx.Owner, ctx.Repo, localBranch) + return nil + }, + }, } } @@ -574,3 +640,20 @@ func numberField(m map[string]interface{}, key string) (float64, bool) { return 0, false } } + +// extractHeadBranch returns the source branch name from a PR API response. +func extractHeadBranch(env *output.Envelope) (string, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected PR response format") + } + pr, ok := data["pull_request"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("PR response missing pull_request field") + } + head := stringField(pr, "head") + if head == "" { + return "", fmt.Errorf("PR response missing pull_request.head branch") + } + return head, nil +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 85ac32d..2f8eb3e 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -1,10 +1,14 @@ package shortcuts import ( + "fmt" + "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/internal/capability" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" + capShortcut "github.com/gitlink-org/gitlink-cli/shortcuts/capability" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" @@ -15,8 +19,8 @@ import ( "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/pm" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" @@ -27,6 +31,9 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) +// SharedRegistry is the global capability registry, shared across the CLI. +var SharedRegistry = capShortcut.SharedRegistry + // RegisterAll mounts all shortcut groups onto the root command. func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr := i18n.Default() @@ -34,49 +41,51 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(tr), - "issue": issue.Shortcuts(tr), - "label": label.Shortcuts(), - "member": member.Shortcuts(), - "milestone": milestone.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "label": label.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), "notification": notification.Shortcuts(), - "pipeline": pipeline.Shortcuts(), - "pm": pm.Shortcuts(), - "pr": pr.Shortcuts(tr), - "release": release.Shortcuts(tr), - "branch": branch.Shortcuts(tr), - "org": org.Shortcuts(tr), - "user": user.Shortcuts(tr), - "search": search.Shortcuts(tr), - "ci": ci.Shortcuts(tr), - "compare": compare.Shortcuts(), - "export": export.Shortcuts(), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), - "workflow": workflow.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pm": pm.Shortcuts(), + "pr": pr.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), + "compare": compare.Shortcuts(), + "export": export.Shortcuts(), + "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), + "workflow": workflow.Shortcuts(), + "capability": capShortcut.Shortcuts(), } descriptions := map[string]string{ - "repo": tr.T("cmd.repo.short"), - "issue": tr.T("cmd.issue.short"), - "label": "Issue label operations", - "member": "Repository member operations", - "milestone": "Milestone operations", - "notification": "Notification operations", - "pipeline": "Pipeline operations", - "pm": "Project management operations", - "pr": tr.T("cmd.pr.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"), - "ci": tr.T("cmd.ci.short"), - "compare": "Compare branches, tags, or commits", - "export": "Data export to CSV/JSON", - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page operations", - "workflow": "AI agent workflow analysis", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), + "label": annotatedDesc("Issue label operations", "label"), + "member": annotatedDesc("Repository member operations", "member"), + "milestone": annotatedDesc("Milestone operations", "milestone"), + "notification": annotatedDesc("Notification operations", "notification"), + "pipeline": annotatedDesc("Pipeline operations", "pipeline"), + "pm": annotatedDesc("Project management operations", "pm"), + "pr": tr.T("cmd.pr.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": annotatedDesc(tr.T("cmd.search.short"), "search"), + "ci": tr.T("cmd.ci.short"), + "compare": "Compare branches, tags, or commits", + "export": annotatedDesc("Data export to CSV/JSON", "export"), + "webhook": annotatedDesc(tr.T("cmd.webhook.short"), "webhook"), + "wiki": annotatedDesc("Wiki page operations", "wiki"), + "workflow": annotatedDesc("AI agent workflow analysis", "workflow"), + "capability": "API backend capability probing", } for name, shortcuts := range groups { @@ -88,3 +97,17 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { root.AddCommand(groupCmd) } } + +// annotatedDesc appends a capability status indicator to the description. +// Available domains show "✓", unavailable show "⚠", unknown show nothing. +func annotatedDesc(base, domain string) string { + status := SharedRegistry.Get(domain) + switch status { + case capability.StatusAvailable: + return fmt.Sprintf("%s ✓", base) + case capability.StatusUnavailable, capability.StatusError: + return fmt.Sprintf("%s ⚠", base) + default: + return base + } +} diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index e3f1351..e0b5370 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,6 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "pr", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", + "notification", "wiki", "export", "pm", "capability", } groupSet := map[string]bool{} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 93cbec8..e274d48 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -92,7 +92,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - // Get current user login for the create path userEnv, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { return fmt.Errorf("failed to get current user: %w", err) @@ -259,6 +258,202 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "raw", + Description: "Get raw file content from a repository", + Flags: []common.Flag{ + {Name: "filepath", Short: "f", Usage: "Path to the file", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA (default: default branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filepath) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/raw", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "activity", + Description: "List recent activity/events 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()+"/activity", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "compare", + Description: "Compare two commits, branches, or tags", + Flags: []common.Flag{ + {Name: "from", Short: "f", Usage: "Base ref (branch/tag/SHA)", Required: true}, + {Name: "to", Short: "t", Usage: "Head ref (branch/tag/SHA)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + from, err := ctx.RequireArg("from") + if err != nil { + return err + } + to, err := ctx.RequireArg("to") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), from, to), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create-file", + Description: "Create a new file in a repository", + Flags: []common.Flag{ + {Name: "filepath", Short: "f", Usage: "Path for the new file", Required: true}, + {Name: "content", Short: "c", Usage: "File content (base64 or plain text)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch (default: default branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + body := map[string]interface{}{ + "filepath": filepath, + "content": content, + "message": message, + } + if branch := ctx.Arg("branch"); branch != "" { + body["branch"] = branch + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/contents", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete-file", + Description: "Delete a file in a repository", + Flags: []common.Flag{ + {Name: "filepath", Short: "f", Usage: "Path of the file to delete", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "sha", Short: "s", Usage: "SHA of the file being deleted"}, + {Name: "branch", Short: "b", Usage: "Target branch (default: default branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + body := map[string]interface{}{ + "filepath": filepath, + "message": message, + } + if sha := ctx.Arg("sha"); sha != "" { + body["sha"] = sha + } + if branch := ctx.Arg("branch"); branch != "" { + body["branch"] = branch + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/contents", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update-file", + Description: "Update an existing file in a repository", + Flags: []common.Flag{ + {Name: "filepath", Short: "f", Usage: "Path of the file to update", Required: true}, + {Name: "content", Short: "c", Usage: "New file content (base64 or plain text)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "sha", Short: "s", Usage: "SHA of the file being replaced"}, + {Name: "branch", Short: "b", Usage: "Target branch (default: default branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + body := map[string]interface{}{ + "filepath": filepath, + "content": content, + "message": message, + } + if sha := ctx.Arg("sha"); sha != "" { + body["sha"] = sha + } + if branch := ctx.Arg("branch"); branch != "" { + body["branch"] = branch + } + env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/contents", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 2008664..5f960db 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -544,3 +544,313 @@ func TestRepoCommitsHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +// --- create-file --- + +func TestRepoCreateFile(t *testing.T) { + var body 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/contents.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{ + "content": map[string]interface{}{"name": "README.md", "path": "README.md"}, + "commit": map[string]interface{}{"message": "Add README"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "create-file", map[string]string{ + "filepath": "README.md", + "content": "SGVsbG8gV29ybGQ=", + "message": "Add README", + }) + if err != nil { + t.Fatalf("create-file failed: %v", err) + } + if body["filepath"] != "README.md" { + t.Fatalf("filepath = %v", body["filepath"]) + } + if body["message"] != "Add README" { + t.Fatalf("message = %v", body["message"]) + } +} + +func TestRepoCreateFileWithBranch(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{"content": map[string]interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "create-file", map[string]string{ + "filepath": "src/main.go", + "content": "cGFja2FnZSBtYWlu", + "message": "Add main.go", + "branch": "feature-branch", + }) + if err != nil { + t.Fatalf("create-file with branch failed: %v", err) + } + if body["branch"] != "feature-branch" { + t.Fatalf("branch = %v", body["branch"]) + } +} + +func TestRepoCreateFileFailsWithoutFilepath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "create-file", map[string]string{ + "content": "SGVsbG8=", + "message": "test", + }) + if err == nil { + t.Fatal("expected error for missing filepath") + } +} + +func TestRepoCreateFileFailsWithoutContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "create-file", map[string]string{ + "filepath": "README.md", + "message": "test", + }) + if err == nil { + t.Fatal("expected error for missing content") + } +} + +func TestRepoCreateFileHTTPError(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, "create-file", map[string]string{ + "filepath": "README.md", + "content": "SGVsbG8=", + "message": "test", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- update-file --- + +func TestRepoUpdateFile(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/contents.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{ + "content": map[string]interface{}{"name": "README.md", "path": "README.md"}, + "commit": map[string]interface{}{"message": "Update README"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "update-file", map[string]string{ + "filepath": "README.md", + "content": "SGVsbG8gV29ybGQ=", + "message": "Update README", + "sha": "abc123", + }) + if err != nil { + t.Fatalf("update-file failed: %v", err) + } + if body["sha"] != "abc123" { + t.Fatalf("sha = %v", body["sha"]) + } +} + +func TestRepoUpdateFileWithBranch(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{"content": map[string]interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "update-file", map[string]string{ + "filepath": "README.md", + "content": "SGVsbG8=", + "message": "Update", + "branch": "develop", + }) + if err != nil { + t.Fatalf("update-file with branch failed: %v", err) + } + if body["branch"] != "develop" { + t.Fatalf("branch = %v", body["branch"]) + } +} + +func TestRepoUpdateFileFailsWithoutFilepath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "update-file", map[string]string{ + "content": "SGVsbG8=", + "message": "test", + }) + if err == nil { + t.Fatal("expected error for missing filepath") + } +} + +func TestRepoUpdateFileHTTPError(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, "update-file", map[string]string{ + "filepath": "README.md", + "content": "SGVsbG8=", + "message": "test", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- delete-file --- + +func TestRepoDeleteFile(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/contents.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{ + "commit": map[string]interface{}{"message": "Delete README"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "delete-file", map[string]string{ + "filepath": "README.md", + "message": "Delete README", + }) + if err != nil { + t.Fatalf("delete-file failed: %v", err) + } + if body["filepath"] != "README.md" { + t.Fatalf("filepath = %v", body["filepath"]) + } + if body["message"] != "Delete README" { + t.Fatalf("message = %v", body["message"]) + } +} + +func TestRepoDeleteFileWithSHA(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{"commit": map[string]interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "delete-file", map[string]string{ + "filepath": "obsolete.txt", + "message": "Remove obsolete file", + "sha": "def456", + }) + if err != nil { + t.Fatalf("delete-file with sha failed: %v", err) + } + if body["sha"] != "def456" { + t.Fatalf("sha = %v", body["sha"]) + } +} + +func TestRepoDeleteFileWithBranch(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, map[string]interface{}{"commit": map[string]interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "delete-file", map[string]string{ + "filepath": "old.txt", + "message": "Remove old file", + "branch": "cleanup", + }) + if err != nil { + t.Fatalf("delete-file with branch failed: %v", err) + } + if body["branch"] != "cleanup" { + t.Fatalf("branch = %v", body["branch"]) + } +} + +func TestRepoDeleteFileFailsWithoutFilepath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "delete-file", map[string]string{ + "message": "test", + }) + if err == nil { + t.Fatal("expected error for missing filepath") + } +} + +func TestRepoDeleteFileFailsWithoutMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "delete-file", map[string]string{ + "filepath": "README.md", + }) + if err == nil { + t.Fatal("expected error for missing message") + } +} + +func TestRepoDeleteFileHTTPError(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, "delete-file", map[string]string{ + "filepath": "README.md", + "message": "Delete README", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index ab81f91..b7f70e0 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -52,77 +52,77 @@ 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: "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"}, }, - { - 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 + 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) } - 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) - }, + } + 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) }, }, } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 870f51a..dd7d19e 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -1,25 +1,61 @@ package wiki import ( + "encoding/base64" "fmt" + "net/url" + "strconv" + "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns wiki management shortcuts for GitLink. +// GitLink 后端 Wiki API 端点(集中式控制器模式,非嵌套资源模式) // -// The wiki domain provides commands for listing, viewing, creating, -// updating, and deleting wiki pages within a repository. +// 后端真实端点(来自 API 参考文档): +// +// GET /api/wiki/wikiPages — 列出页面(query: owner, projectId, repo) +// GET /api/wiki/getWiki — 获取页面(query: owner, pageName, projectId, repo) +// POST /api/wiki/createWiki — 创建页面(body: owner, repo, projectId, pageName, title, content_base64, message) +// PUT /api/wiki/updateWiki — 更新页面(body: owner, repo, projectId, pageName, title, content_base64, message) +// DELETE /api/wiki/deleteWiki — 删除页面(body: owner, repo, projectId, pageName) +// +// 注意:Wiki 控制器只接受 autologin_trustie cookie 认证,不支持 access_token。 +// 用户需先运行 `gitlink-cli auth login` 登录获取 session cookie。 +const ( + apiWikiPages = "/api/wiki/wikiPages" + apiWikiGet = "/api/wiki/getWiki" + apiWikiCreate = "/api/wiki/createWiki" + apiWikiUpdate = "/api/wiki/updateWiki" + apiWikiDelete = "/api/wiki/deleteWiki" +) + +// Shortcuts returns wiki management shortcuts for GitLink. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "pages", Description: "列出 Wiki 页面", + Flags: []common.Flag{ + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, + }, Run: func(ctx *common.RuntimeContext) error { + if err := requireCookieAuth(ctx); err != nil { + return err + } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", "/api/wiki/wikiPages", nil) + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + query := url.Values{ + "owner": {ctx.Owner}, + "repo": {ctx.Repo}, + "projectId": {projectID}, + } + env, err := ctx.CallAPIWithQuery("GET", apiWikiPages, query) if err != nil { return err } @@ -30,15 +66,31 @@ func Shortcuts() []*common.Shortcut { Name: "get", Description: "获取 Wiki 页面内容", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, }, Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") + if err := requireCookieAuth(ctx); err != nil { + return err + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + pageName, err := ctx.RequireArg("page") if err != nil { return err } - path := fmt.Sprintf("/api/wiki/getWiki?id=%s", id) - env, err := ctx.CallAPI("GET", path, nil) + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + query := url.Values{ + "owner": {ctx.Owner}, + "repo": {ctx.Repo}, + "pageName": {pageName}, + "projectId": {projectID}, + } + env, err := ctx.CallAPIWithQuery("GET", apiWikiGet, query) if err != nil { return err } @@ -49,11 +101,15 @@ func Shortcuts() []*common.Shortcut { Name: "create", Description: "创建 Wiki 页面", Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "页面标题", Required: true}, - {Name: "content", Short: "c", Usage: "页面内容(Markdown)", Required: true}, - {Name: "project", Usage: "项目 ID"}, + {Name: "title", Short: "t", Usage: "页面标题(同时作为 pageName)", Required: true}, + {Name: "content", Short: "c", Usage: "页面内容(Markdown,将自动 base64 编码)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, + {Name: "message", Short: "m", Usage: "提交信息"}, }, Run: func(ctx *common.RuntimeContext) error { + if err := requireCookieAuth(ctx); err != nil { + return err + } if err := ctx.ResolveOwnerRepo(); err != nil { return err } @@ -65,14 +121,22 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } body := map[string]interface{}{ - "title": title, - "content": content, + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": title, + "title": title, + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), } - if project := ctx.Arg("project"); project != "" { - body["project_id"] = project + if msg := ctx.Arg("message"); msg != "" { + body["message"] = msg } - env, err := ctx.CallAPI("POST", "/api/wiki/createWiki", body) + env, err := ctx.CallAPI("POST", apiWikiCreate, body) if err != nil { return err } @@ -83,23 +147,43 @@ func Shortcuts() []*common.Shortcut { Name: "update", Description: "更新 Wiki 页面", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName,必填)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, {Name: "title", Short: "t", Usage: "新标题"}, - {Name: "content", Short: "c", Usage: "新内容(Markdown)"}, + {Name: "content", Short: "c", Usage: "新内容(Markdown,将自动 base64 编码)"}, + {Name: "message", Short: "m", Usage: "提交信息"}, }, Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") + if err := requireCookieAuth(ctx); err != nil { + return err + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + pageName, err := ctx.RequireArg("page") if err != nil { return err } - body := map[string]interface{}{"id": id} + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": pageName, + } if t := ctx.Arg("title"); t != "" { body["title"] = t } if c := ctx.Arg("content"); c != "" { - body["content"] = c + body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(c)) } - env, err := ctx.CallAPI("PUT", "/api/wiki/updateWiki", body) + if msg := ctx.Arg("message"); msg != "" { + body["message"] = msg + } + env, err := ctx.CallAPI("PUT", apiWikiUpdate, body) if err != nil { return err } @@ -110,15 +194,31 @@ func Shortcuts() []*common.Shortcut { Name: "delete", Description: "删除 Wiki 页面", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, }, Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") + if err := requireCookieAuth(ctx); err != nil { + return err + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + pageName, err := ctx.RequireArg("page") if err != nil { return err } - body := map[string]interface{}{"id": id} - env, err := ctx.CallAPI("POST", "/api/wiki/deleteWiki", body) + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": pageName, + } + env, err := ctx.CallAPI("DELETE", apiWikiDelete, body) if err != nil { return err } @@ -127,3 +227,28 @@ func Shortcuts() []*common.Shortcut { }, } } + +// mustAtoi converts a string to integer for projectId, panicking on failure +// (the value should have been validated by RequireArg already). +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +// requireCookieAuth checks that cookie-based auth is available and configures +// the runtime context to use a cookie-only HTTP client, bypassing GITLINK_TOKEN. +// GitLink Wiki API only accepts autologin_trustie cookie, not access_token. +func requireCookieAuth(ctx *common.RuntimeContext) error { + if !auth.IsCookieAuth() { + diag := auth.AuthDiagnostic() + return fmt.Errorf("Wiki 功能需要 session cookie 认证,不支持 access_token。\n"+ + "诊断信息: %s\n"+ + "请运行以下命令登录:\n"+ + " gitlink-cli auth login", diag) + } + // Switch to cookie-based HTTP client for wiki API calls. + // This ensures the stored cookie token is used even when + // GITLINK_TOKEN environment variable is set with a non-cookie value. + ctx.Client.HTTP = auth.NewCookieHTTPClient() + return nil +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 7712815..0e59bcf 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,9 +1,12 @@ package wiki import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -11,17 +14,119 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// --- helpers --- + +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 +} + +func baseOwnerRepoArgs() map[string]string { + return map[string]string{ + "project-id": "1406620", + } +} + +// setCookieAuth sets GITLINK_TOKEN to cookie format for testing. +// Returns a cleanup function to restore the original value. +func setCookieAuth() func() { + orig := os.Getenv("GITLINK_TOKEN") + os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=test-session-cookie") + return func() { + if orig == "" { + os.Unsetenv("GITLINK_TOKEN") + } else { + os.Setenv("GITLINK_TOKEN", orig) + } + } +} + +// --- cookie auth guard --- + +func TestWikiCookieAuthUsesStoredCookie(t *testing.T) { + // Set env to a non-cookie access token — this simulates the real-world scenario + // where GITLINK_TOKEN is set to a personal access token while a cookie is stored + // via `auth login`. IsCookieAuth() should detect the stored cookie fallback, + // and CookieTransport should send the cookie header on wiki API calls. + orig := os.Getenv("GITLINK_TOKEN") + os.Setenv("GITLINK_TOKEN", "plain-access-token-12345") + defer func() { + if orig == "" { + os.Unsetenv("GITLINK_TOKEN") + } else { + os.Setenv("GITLINK_TOKEN", orig) + } + }() + + for _, name := range []string{"pages", "get", "create", "update", "delete"} { + t.Run(name, func(t *testing.T) { + var gotCookie string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookie = r.Header.Get("Cookie") + w.WriteHeader(200) + w.Write([]byte(`{"data": {}, "message": "200"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "project-id": "1", + "page": "x", + "title": "x", + "content": "x", + }, + } + err := shortcut.Run(ctx) + // If a cookie is stored (from auth login), the shortcut should proceed + // and make the API call. If no cookie is stored, we get the cookie auth error. + if err != nil { + // Cookie auth error is acceptable if no cookie is stored on this machine + if strings.Contains(err.Error(), "session cookie") { + t.Skipf("no stored cookie available on this machine, skipping %s", name) + } + t.Fatalf("shortcut %s failed: %v", name, err) + } + // If we got here, the API call was made — verify cookie was sent + if gotCookie == "" { + t.Errorf("expected Cookie header to be set for %s", name) + } + }) + } +} + +// --- pages --- + func TestWikiPages(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + tests := []struct { - name string - mockStatus int - mockBody string - wantErr bool - errContains string + name string + mockStatus int + mockBody string + wantErr bool + errContain string }{ - {"正常返回", 200, `{"wikiPages": []}`, false, ""}, - {"API 404", 404, `{"error": "not found"}`, true, "404"}, - {"返回 HTML", 200, `Login`, true, "HTML"}, + {"正常返回", 200, `{"data": [], "message": "200"}`, false, ""}, + {"API 404", 404, `{"message": "not found"}`, true, "404"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -29,6 +134,18 @@ func TestWikiPages(t *testing.T) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } + if !strings.Contains(r.URL.Path, "wikiPages") { + t.Errorf("expected path containing 'wikiPages', got %s", r.URL.Path) + } + if r.URL.Query().Get("owner") != "test" { + t.Errorf("expected owner=test, got %s", r.URL.Query().Get("owner")) + } + if r.URL.Query().Get("repo") != "test" { + t.Errorf("expected repo=test, got %s", r.URL.Query().Get("repo")) + } + if r.URL.Query().Get("projectId") != "1406620" { + t.Errorf("expected projectId=1406620, got %s", r.URL.Query().Get("projectId")) + } w.WriteHeader(tt.mockStatus) w.Write([]byte(tt.mockBody)) })) @@ -38,7 +155,7 @@ func TestWikiPages(t *testing.T) { ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{}, + Args: baseOwnerRepoArgs(), } err := shortcut.Run(ctx) @@ -48,33 +165,44 @@ func TestWikiPages(t *testing.T) { 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()) + if tt.wantErr && tt.errContain != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("错误应包含 %q: %s", tt.errContain, err.Error()) } } }) } } +// --- get --- + func TestWikiGet(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + tests := []struct { - name string - args map[string]string - mockStatus int - mockBody string - wantErr bool - errContains string + name string + args map[string]string + mockStatus int + mockBody string + wantErr bool }{ - {"正常获取", 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"}, + {"正常获取", map[string]string{"page": "Home", "project-id": "1406620"}, 200, `{"data": {"title": "Home"}, "message": "200"}`, false}, + {"缺少 page", map[string]string{"project-id": "1406620"}, 200, `{}`, true}, } 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) + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "getWiki") { + t.Errorf("expected path containing 'getWiki', got %s", r.URL.Path) + } + if tt.args["page"] != "" { + if r.URL.Query().Get("pageName") != tt.args["page"] { + t.Errorf("expected pageName=%s, got %s", tt.args["page"], r.URL.Query().Get("pageName")) + } } w.WriteHeader(tt.mockStatus) w.Write([]byte(tt.mockBody)) @@ -99,18 +227,23 @@ func TestWikiGet(t *testing.T) { } } +// --- create --- + func TestWikiCreate(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + 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) + if !strings.Contains(r.URL.Path, "createWiki") { + t.Errorf("expected path containing 'createWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "201", "data": "{}"}`)) })) defer server.Close() @@ -119,49 +252,47 @@ func TestWikiCreate(t *testing.T) { 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", + "title": "Getting Started", + "content": "# Hello\nWelcome to the wiki", + "project-id": "1406620", }, } 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"]) + if payload["pageName"] != "Getting Started" { + t.Errorf("expected pageName 'Getting Started', got %v", payload["pageName"]) } -} - -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() - - 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 payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) } - if err := shortcut.Run(ctx); err != nil { - t.Fatalf("create shortcut failed: %v", err) + if payload["repo"] != "test" { + t.Errorf("expected repo 'test', got %v", payload["repo"]) } - if payload["project_id"] != "123" { - t.Errorf("expected project_id '123', got %v", payload["project_id"]) + if payload["projectId"] != float64(1406620) { + t.Errorf("expected projectId 1406620, got %v", payload["projectId"]) + } + encoded, ok := payload["content_base64"].(string) + if !ok { + t.Fatalf("content_base64 is not a string: %v", payload["content_base64"]) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode content_base64: %v", err) + } + if string(decoded) != "# Hello\nWelcome to the wiki" { + t.Errorf("unexpected decoded content: %s", decoded) } } func TestWikiCreateMissingTitle(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call API when --title is missing") })) @@ -171,25 +302,81 @@ func TestWikiCreateMissingTitle(t *testing.T) { 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"}, + Args: map[string]string{"content": "only content", "project-id": "1"}, } if err := shortcut.Run(ctx); err == nil { t.Fatal("expected error when --title is missing") } } +func TestWikiCreateMissingContent(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --content 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{"title": "Only title", "project-id": "1"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --content is missing") + } +} + +func TestWikiCreateWithMessage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + 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(`{"message": "201", "data": "{}"}`)) + })) + 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{ + "title": "Test", + "content": "Body", + "project-id": "123", + "message": "add wiki page", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["message"] != "add wiki page" { + t.Errorf("expected message 'add wiki page', got %v", payload["message"]) + } +} + +// --- update --- + func TestWikiUpdate(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + 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) + if !strings.Contains(r.URL.Path, "updateWiki") { + t.Errorf("expected path containing 'updateWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "200", "data": "{}"}`)) })) defer server.Close() @@ -198,34 +385,72 @@ func TestWikiUpdate(t *testing.T) { 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", + "page": "Getting-Started", + "project-id": "1406620", + "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["pageName"] != "Getting-Started" { + t.Errorf("expected pageName 'Getting-Started', got %v", payload["pageName"]) } if payload["title"] != "Updated Title" { t.Errorf("expected title 'Updated Title', got %v", payload["title"]) } + if payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) + } + encoded, _ := payload["content_base64"].(string) + decoded, _ := base64.StdEncoding.DecodeString(encoded) + if string(decoded) != "Updated content" { + t.Errorf("unexpected decoded content: %s", decoded) + } } +func TestWikiUpdateMissingPage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --page is missing") + })) + defer server.Close() + + 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{ + "project-id": "1406620", + "title": "No page", + }, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --page is missing") + } +} + +// --- delete --- + func TestWikiDelete(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + 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 r.Method != "DELETE" { + t.Errorf("expected DELETE, 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) + if !strings.Contains(r.URL.Path, "deleteWiki") { + t.Errorf("expected path containing 'deleteWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "200", "data": "{}"}`)) })) defer server.Close() @@ -233,49 +458,167 @@ func TestWikiDelete(t *testing.T) { ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{"id": "42"}, + Args: map[string]string{ + "page": "Old-Page", + "project-id": "1406620", + }, } 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"]) + if payload["pageName"] != "Old-Page" { + t.Errorf("expected pageName 'Old-Page', got %v", payload["pageName"]) + } + if payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) } } -func TestWikiDeleteMissingId(t *testing.T) { +func TestWikiDeleteMissingPage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not call API when --id is missing") + t.Fatal("should not call API when --page 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{"project-id": "1406620"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --page is missing") + } +} + +// --- URL 构建验证 --- + +func TestWikiPathsUseCorrectAPIEndpoint(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + tests := []struct { + shortcutName string + args map[string]string + wantMethod string + wantPath string + }{ + {"pages", map[string]string{"project-id": "1"}, "GET", "/wiki/wikiPages"}, + {"get", map[string]string{"page": "Home", "project-id": "1"}, "GET", "/wiki/getWiki"}, + } + for _, tt := range tests { + t.Run(tt.shortcutName, func(t *testing.T) { + var gotMethod, gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = strings.TrimSuffix(r.URL.Path, ".json") + w.WriteHeader(200) + w.Write([]byte(`{"data": {}, "message": "200"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, tt.shortcutName) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: tt.args, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("shortcut %s failed: %v", tt.shortcutName, err) + } + if gotMethod != tt.wantMethod { + t.Errorf("method: got %s, want %s", gotMethod, tt.wantMethod) + } + if !strings.Contains(gotPath, tt.wantPath) { + t.Errorf("path: got %s, want containing %s", gotPath, tt.wantPath) + } + }) + } +} + +// --- 验证 --id 标志不再存在 --- + +func TestWikiFlagsUsePageNotID(t *testing.T) { + for _, name := range []string{"get", "update", "delete"} { + shortcut := findWikiShortcut(t, name) + for _, flag := range shortcut.Flags { + if flag.Name == "id" { + t.Errorf("shortcut %q still uses --id flag; should use --page for pageName", name) + } + } + } +} + +// --- 验证 --project-id 不再是 Required --- + +func TestWikiProjectIDNotRequired(t *testing.T) { + for _, name := range []string{"pages", "get", "create", "update", "delete"} { + shortcut := findWikiShortcut(t, name) + for _, flag := range shortcut.Flags { + if flag.Name == "project-id" && flag.Required { + t.Errorf("shortcut %q: --project-id should not be Required (auto-resolved)", name) + } + } + } +} + +// --- 自动解析 project-id --- + +func TestWikiAutoResolveProjectID(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + repoInfoCalled := false + var wikiQueryProjectID string + + mux := http.NewServeMux() + mux.HandleFunc("/test/test", func(w http.ResponseWriter, r *http.Request) { + repoInfoCalled = true + w.WriteHeader(200) + fmt.Fprintf(w, `{"id": 999888, "name": "test", "full_name": "test/test"}`) + }) + mux.HandleFunc("/api/wiki/wikiPages", func(w http.ResponseWriter, r *http.Request) { + wikiQueryProjectID = r.URL.Query().Get("projectId") + w.WriteHeader(200) + w.Write([]byte(`{"data": [], "message": "200"}`)) + }) + // Also handle the path without /api prefix (depends on BaseURL) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "wikiPages") { + wikiQueryProjectID = r.URL.Query().Get("projectId") + w.WriteHeader(200) + w.Write([]byte(`{"data": [], "message": "200"}`)) + return + } + if strings.Contains(r.URL.Path, "test/test") { + repoInfoCalled = true + w.WriteHeader(200) + fmt.Fprintf(w, `{"id": 999888, "name": "test", "full_name": "test/test"}`) + return + } + w.WriteHeader(404) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + shortcut := findWikiShortcut(t, "pages") + // No project-id in args — should auto-resolve 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") + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("pages with auto-resolve failed: %v", err) + } + if !repoInfoCalled { + t.Error("expected repo info API to be called for auto-resolve") + } + if wikiQueryProjectID != "999888" { + t.Errorf("expected projectId=999888, got %s", wikiQueryProjectID) } } - -func findWikiShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func decodeWikiJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} diff --git a/skills/gitlink-code-review/SKILL.md b/skills/gitlink-code-review/SKILL.md index e43b0ab..4a617d6 100644 --- a/skills/gitlink-code-review/SKILL.md +++ b/skills/gitlink-code-review/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-code-review version: 1.0.0 -description: "智能代码审查:获取 PR 变更、分析代码质量、自动生成 Review 评论与摘要报告。当用户需要审查 Pull Request、检查代码质量或生成审查报告时触发。" +description: "智能代码审查:分析 PR diff,输出结构化 Review 意见并自动评论。当用户需要代码审查、检查 PR 质量、Review 代码变更时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -11,311 +11,174 @@ metadata: # gitlink-code-review(智能代码审查) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有写入/删除操作前,务必先确认用户意图。** -**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**CRITICAL — 本 Skill 为只读分析,`pr +review` 为写操作,执行前需确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 -## 工作流概览 +--- -本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。 +## 功能概述 -| 阶段 | 操作 | AI Agent 角色 | -|------|------|--------------| -| ① 获取上下文 | 拉取 PR 详情、变更文件、Diff | 执行 CLI 命令采集数据 | -| ② 分析代码 | 检查每个文件的变更 | 逐文件审查,标记问题 | -| ③ 结构化反馈 | 按严重程度分级输出审查意见 | 生成分级 Review 评论 | -| ④ 提交评论 | 发表 Review 到 PR | 通过 API 提交 | -| ⑤ 生成报告 | 输出审查摘要 | 生成 Markdown 摘要 | +自动分析 PR 变更内容,输出结构化代码审查意见: + +1. **变更概览** — 统计文件数、新增/删除行数 +2. **代码质量检查** — 检查代码风格、潜在 Bug、性能问题 +3. **安全审查** — 识别硬编码密钥、注入风险、权限问题 +4. **测试覆盖率** — 评估是否有足够的测试覆盖 +5. **Review 意见** — 生成分段审查意见,支持自动评论 --- -## 详细工作流 +## 工作流:PR 代码审查 -### 工作流 1:PR 代码审查 - -**场景**:收到 PR Review 请求后,进行完整代码审查。 - -#### Step 1:获取 PR 上下文 +### Step 1:获取仓库基本信息 ```bash -# 获取 PR 详情 -gitlink-cli pr +view --id --format json - -# 获取变更文件列表 -gitlink-cli pr +files --id --format json - -# 获取 Diff 内容(含变更行号和代码上下文) -gitlink-cli pr +diff --id --format json -``` - -#### Step 2:逐文件分析 - -对每个变更文件,根据文件类型执行针对性检查: - -**Python 文件检查项:** -- 语法与导入:未使用的 import、循环导入、wildcard import -- 代码规范:PEP 8 风格偏离、过长行(>88 chars)、命名规范 -- 安全:硬编码密钥、SQL 注入风险、`eval()`/`exec()` 使用 -- 性能:不必要的循环、缺少缓存、N+1 查询 -- 错误处理:裸 `except`、吞异常、缺少 finally - -**JavaScript/TypeScript 文件检查项:** -- 安全:`innerHTML` 直接赋值、`eval()` 使用 -- 类型安全:`any` 滥用、缺失类型定义 -- 性能:不必要的 re-render、大对象深拷贝 -- 异步:未处理的 Promise、缺少 error boundary -- 依赖:已废弃 API 使用 - -**Go 文件检查项:** -- 错误处理:未检查的 error return、panic 滥用 -- 并发:goroutine 泄漏、缺少 sync 保护 -- 资源管理:未关闭的 file/conn、defer 使用 -- 命名:导出标识符缺少注释、变量 shadowing - -**通用检查项:** -- 硬编码的配置值、密钥、URL -- 缺少或错误的边界条件检查 -- 过于复杂的函数(圈复杂度高) -- 魔法数字(未命名的常量) -- 重复代码(DRY 违反) -- 缺少或过时的注释 -- 测试覆盖不足 - -#### Step 3:生成结构化审查结果 - -按以下 Severity 分级输出: - -```markdown -## PR # 代码审查报告 - -### 🔴 Critical(必须修改) -- <问题描述> — <文件>:<行号> - > <修改建议> - -### 🟡 Warning(建议修改) -- <问题描述> — <文件>:<行号> - > <修改建议> - -### 🔵 Suggestion(可选优化) -- <问题描述> — <文件>:<行号> - > <修改建议> - -### ✅ Positive(值得肯定) -- <做得好的地方> -``` - -#### Step 4:提交 Review 评论 - -```bash -# 方式 1:提交整体 Review -gitlink-cli pr +review --body '{ - "body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价:...", - "event": "COMMENT" -}' - -# 方式 2:在特定行添加内联评论(逐条提交) -gitlink-cli pr +review --body '{ - "body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。", - "event": "COMMENT", - "commit_id": "", - "path": "src/query.py", - "position": 42 -}' -``` - -> **注意:** `event` 参数支持 `COMMENT`(普通评论)和 `APPROVE`(批准)。对于需要修改的问题,使用 `COMMENT`。 - -#### Step 5:生成审查摘要 - -审查完成后,输出 Markdown 摘要供用户查阅: - -```markdown -## 📋 审查摘要 — PR # - -| 指标 | 数据 | -|------|------| -| 审查文件数 | <n> | -| 变更行数 | +<add> / -<del> | -| Critical 问题 | <n> | -| Warning | <n> | -| Suggestion | <n> | - -### 主要发现 -1. **[Critical]** <最严重的问题> -2. **[Warning]** <次要问题> -3. **[Suggestion]** <优化建议> - -### 总体评价 -<整体评估:代码质量、审查通过建议> - ---- -*由 gitlink-code-review Skill 自动生成* -``` - ---- - -### 工作流 2:仓库代码健康度扫描 - -**场景**:对仓库整体代码质量进行评估,不依赖 PR。 - -```bash -# 1. 获取仓库信息 gitlink-cli repo +info --owner <owner> --repo <repo> --format json - -# 2. 获取仓库文件列表(遍历关键目录) -gitlink-cli repo +files --query 'filepath=src&ref=master' -gitlink-cli repo +files --query 'filepath=tests&ref=master' - -# 3. 获取关键文件内容 -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 repo +languages -gitlink-cli repo +contributors ``` -**健康度检查清单:** +确认仓库存在,提取 `full_name`。 -| 检查项 | 标准 | 评分依据 | -|--------|------|----------| -| 文档完整性 | 有 README、CONTRIBUTING、CHANGELOG | 文件是否存在、内容质量 | -| 许可证 | 有 LICENSE 文件 | 是否存在、是否合规 | -| CI 配置 | 有 CI 配置(.github/workflows, Jenkinsfile 等) | 文件是否存在 | -| 代码规范 | 有 linter 配置 | eslint/prettier/ruff/pylint 等 | -| 测试覆盖 | 有 test 目录或测试文件 | 测试文件比例 | -| 依赖管理 | 依赖文件完整且无已知漏洞 | package-lock/go.sum/poetry.lock | -| Issue 健康度 | Issue 有分类标签、响应及时 | 通过 Issue 列表分析 | +### Step 2:获取开放 PR 列表 -**输出格式:** +```bash +gitlink-cli pr +list --owner <owner> --repo <repo> --format json +``` + +如指定 PR 编号则跳过此步。如未指定,选择最新 PR 或显示列表让用户选择。 + +### Step 3:获取 PR 详情 + +```bash +gitlink-cli pr +view --owner <owner> --repo <repo> --id <pr_number> --format json +``` + +提取:`title`(标题,API可能用`name`,两者都尝试), `body`(描述), `pull_request_base`(目标分支,嵌套对象取`.ref`), `pull_request_head`(源分支), `author_login`(作者), `journals_count`(评论数), `pull_request_status`(状态,注意API可能有拼写错误`pull_request_staus`)。 + +### Step 3.5:获取 PR 变更文件(增强审查精度) + +```bash +gitlink-cli pr +files --owner <owner> --repo <repo> --id <pr_number> --format json +``` + +提取文件数量、涉及路径,用于判断影响范围。 + +### Step 4:多维审查 + +#### 4.1 变更规模评估 + +| 指标 | 评估标准 | +|------|----------| +| 变更类型 | 从标题判断:feat/fix/refactor/docs/test/ci/other | +| 影响范围 | 基于 `pr +files` 结果:≤2文件=小, 3-5=中, >5=大 | + +#### 4.2 代码质量检查 + +| 检查项 | 风险信号 | +|--------|----------| +| 标题规范性 | 无 `feat:`/`fix:` 等前缀 → 建议规范化 | +| 描述完整性 | `body` 为空 → 评分0,要求补充 | +| 单一职责 | 标题含"和"/"以及" → 建议拆分 | +| 测试覆盖 | 无测试相关描述 → 建议补充测试 | + +#### 4.3 安全检查 + +| 检查项 | 风险信号 | +|--------|----------| +| 硬编码密钥 | 含 `sk-`/`ghp_`/`password =`/`api_key =` 等 | +| 敏感文件 | 修改 `.env`/`credentials`/`secret` 等 | +| 权限变更 | 涉及 CI/CD 配置、部署脚本修改 | + +#### 4.4 协作评分(满分 20) + +| 维度 | 权重 | 评分标准 | +|------|------|----------| +| 标题规范 | 5 | 有类型前缀=5, 标题清晰但无前缀=3, 标题模糊=1 | +| 描述完整 | 5 | 详细描述=5, 简要描述=3, 无描述=0 | +| 变更粒度 | 5 | 单一职责=5, 2-3个变更=3, >3个=1 | +| 测试覆盖 | 5 | 有明确测试=5, 疑似有测试=3, 无测试=1 | + +### Step 5:生成审查报告 + +按下方输出模板生成报告。 + +### Step 6:自动评论(可选,需确认) + +```bash +# 先用 dry-run 预览 +gitlink-cli pr +review --owner <owner> --repo <repo> --id <pr_number> --content "<审查意见>" --dry-run +# 正式提交(需确认) +gitlink-cli pr +review --owner <owner> --repo <repo> --id <pr_number> --content "<审查意见>" --status common +``` +> status: common=评论, approved=批准, rejected=拒绝 +> ⚠️ 先 `--dry-run` 预览,确认后去掉 `--dry-run` 正式提交。 + +--- + +## 输出模板 ```markdown -## 🏥 仓库健康度报告 — <owner>/<repo> +# 🔍 PR 审查报告:#{{pr_number}} {{pr_title}} -### 总体评分:<⭐x/5> +> 审查时间:{{当前时间}} +> 作者:{{author_login}} +> 分支:{{head}} → {{base}} -| 维度 | 状态 | 评分 | 建议 | -|------|:----:|:----:|------| -| 📖 文档 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 🔧 CI/CD | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 🎨 代码规范 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 🧪 测试覆盖 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 📦 依赖安全 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | -| 🐛 Issue 管理 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> | +## 一、变更概览 -### 关键发现 -1. <最需要改进的问题> -2. <次要问题> -3. <做得好的方面> +- 变更类型:{{change_type}} +- 影响范围:{{scope}} +- 描述质量:{{description_quality}} -### 改进路线图 -- **紧急(本周):** ... -- **短期(本月):** ... -- **长期(本季度):** ... +## 二、代码质量评估 + +| 检查项 | 状态 | 说明 | +|--------|------|------| +| 标题规范 | {{title_check}} | | +| 描述完整 | {{desc_check}} | | +| 变更粒度 | {{size_check}} | | +| 测试覆盖 | {{test_check}} | | + +## 三、安全检查 + +| 检查项 | 状态 | +|--------|------| +| 敏感信息 | {{secret_check}} | +| 权限变更 | {{perm_check}} | + +## 四、审查结论 + +| 维度 | 评分 | +|------|------| +| 标题规范 | {{title_score}}/5 | +| 描述完整 | {{desc_score}}/5 | +| 变更粒度 | {{size_score}}/5 | +| 测试覆盖 | {{test_score}}/5 | +| **总分** | **{{total_score}}/20** | + +### 判定 + +- >=16分 Approved ✅ +- 10-15分 Changes Requested ⚠️ +- <10分 Needs Work ❌ ``` --- -### 工作流 3:批量 Issue Triage + 自动分配 +## 异常场景处理 -**场景**:对新 Issue 进行自动分类、标签分配和责任人推荐。 - -```bash -# 1. 获取未标记的 Issue -gitlink-cli issue +list --state open --format json - -# 2. 逐个分析 Issue 内容 -gitlink-cli issue +view --id <issue_id> --format json - -# 3. 根据内容智能分类 -# 分析标题和描述后,通过 Raw API 打标签 -gitlink-cli issue +update --number '{ - "issue_tag_ids": [<tag_id>], - "done_ratio": 0, - "subject": "<原始标题>", - "description": "<原始描述>" -}' -``` - -**分类规则参考:** - -| Issue 关键词 | 推荐标签 | 优先级 | -|-------------|----------|:------:| -| bug, 错误, 失败, crash, 崩溃 | bug | 🔴 High | -| feature, 新增, 建议, 希望 | enhancement | 🔵 Low | -| 安全, 漏洞, 权限, 泄露 | security | 🔴 High | -| 性能, 慢, 卡顿, 优化 | performance | 🟡 Medium | -| 文档, README, 注释 | documentation | 🔵 Low | -| question, 如何, 怎么, 请问 | question | 🟡 Medium | -| 测试, test, 覆盖率 | testing | 🔵 Low | +| 场景 | 处理方式 | +|------|----------| +| 无开放 PR | 报告"当前无待审查的 PR" | +| PR 描述为空 | 标注"描述缺失",描述评分=0 | +| `pr +view` 返回错误 | 标注"无法获取 PR 详情" | --- -## Raw API 参考 - -代码审查相关的 GitLink API 端点: - -```bash -# 获取 PR 详情 -gitlink-cli pr +view --id --format json - -# 获取 PR 变更文件列表 -gitlink-cli pr +files --format json - -# 获取 PR Diff -gitlink-cli pr +diff --format json - -# 提交 PR Review -gitlink-cli pr +review --body '{"body":"...","event":"COMMENT"}' - -# 获取仓库文件列表 -gitlink-cli repo +files --query 'filepath=<path>&ref=<branch>' - -# 获取仓库语言统计 -gitlink-cli repo +languages --format json - -# 获取贡献者列表 -gitlink-cli repo +contributors --format json - -# 获取仓库动态 -gitlink-cli repo +activity --format json -``` - -## 代码审查最佳实践 - -### 审查原则 - -1. **先大局后细节**:先理解 PR 的目的和整体变更范围,再逐文件审查 -2. **关注行为,而非风格**:自动化工具(linter/formatter)能处理的风格问题优先交给工具 -3. **提供可操作的建议**:不只是指出问题,要给出具体的修改方案 -4. **肯定好的代码**:发现好的设计、清晰的命名、完善的测试时给予正面反馈 -5. **控制评论量**:避免信息过载——最严重的 3-5 个问题比 20 个小问题更有价值 - -### 安全红线 - -以下问题必须标记为 **Critical**,不得忽略: - -- 硬编码的密钥 / Token / 密码 -- SQL / NoSQL 注入漏洞 -- 命令注入(shell 命令拼接) -- 路径遍历(用户输入直接用于文件路径) -- 不安全的反序列化 -- XSS(未转义的用户输入直接渲染) - -### 输出规范 - -- 始终使用 `--format json` 获取结构化数据 -- 审查报告输出为 **Markdown 格式**,便于直接粘贴到 PR 评论 -- 涉及文件/行号时使用精准引用,方便定位 -- 批量操作前使用 `--dry-run` 预检 - ## 注意事项 -- PR Review 提交后会通知所有关注该 PR 的参与者,评论内容请保持专业 -- `pr +diff` 输出可能很大(大型 PR),Agent 应分段处理 -- API 的 PR files 和 diff 接口有频率限制,避免短时间内重复请求 -- 对于 draft PR(草稿),应提示用户先将其标记为 Ready for Review +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **`pr +review` 为写操作**,执行前需确认 +- ⚠️ **安全检查依赖关键词匹配**,不能替代专业安全审计 diff --git a/skills/gitlink-community-ops/SKILL.md b/skills/gitlink-community-ops/SKILL.md new file mode 100644 index 0000000..9d932f0 --- /dev/null +++ b/skills/gitlink-community-ops/SKILL.md @@ -0,0 +1,148 @@ +--- +name: gitlink-community-ops +version: 1.0.0 +description: "社区运营端到端自动化编排:当用户需要批量处理新 Issue 分类分配、定期生成社区周报、或发布 Release Notes 时,编排多个子 Skill 完成 Issue 分诊 → 通知汇总 → 周报撰写 → Release 发布的全流程。" +metadata: + requires: + bins: ["gitlink-cli"] + orchestrates: + - gitlink-issue-triage + - gitlink-notification-digest + - gitlink-contributor-insight + - gitlink-release-auto + - gitlink-release-notes + cliHelp: "gitlink-cli --help" +--- + +# gitlink-community-ops(社区运营自动化编排) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),认证/权限/API 注意事项。** +**CRITICAL — 所有写入/删除操作(series-update、release create、issue comment)前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 gitlink-cli。禁止用 gh(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — 本编排型 Skill 只做"导演",指挥子 Skill 与 CLI 命令的执行顺序,不重写子 Skill 的内部逻辑。** + +--- + +## 工作流总览 + +```mermaid +flowchart TD + A[用户触发] --> M{意图判断} + M -->|全流程| S1 + M -->|仅分类| S2 + M -->|仅周报| S1b + M -->|仅Release| S5 + S1[Step1 数据采集<br/>issue+list / export+contributors] --> S2 + S1b[Step1 数据采集<br/>export+issues / +contributors] --> S3 + S2[Step2 🤖AI分类+分配<br/>issue-triage + workflow+triage<br/>issue+series-update] --> S3 + S3[Step3 🤖AI通知汇总<br/>notification-digest<br/>contributor-insight] --> S4 + S4[Step4 🤖AI写周报<br/>WEEKLY-REPORT.md] --> S5{有可发版本?} + S5 -->|是| S6[Step5 🤖AI生成Notes<br/>release-notes + release+create] + S5 -->|否| END + S6 --> END[输出执行清单] +``` + +--- + +## 编排的子 Skill + +| 子 Skill | 职责 | 调用时机 | +|---|---|---| +| gitlink-issue-triage | Issue 自动分类、打标签、判断优先级与责任人 | Step2:新 Issue 分诊 | +| gitlink-notification-digest | 聚合近期通知、未读消息、待办提醒 | Step3:周报素材 | +| gitlink-contributor-insight | 贡献者活跃度、新贡献者识别 | Step3:周报人物板块 | +| gitlink-release-auto | 拉取合并 PR、组装 Release 候选 | Step5:发版前置 | +| gitlink-release-notes | 生成 Release Notes 文案 | Step5:release+create 内容 | + +--- + +## 详细步骤 + +### Step 1: 数据采集(纯命令) + +- 命令: + - `gitlink-cli issue +list --owner <owner> --repo <repo> --state open --limit 50 --format json` — 拉取开放 Issue + - `gitlink-cli export +contributors --owner <owner> --repo <repo> --format json --output contributors.json` — 导出贡献者 + - `gitlink-cli export +issues --owner <owner> --repo <repo> --state all --format json --output issues.json` — 导出全部 Issue 供分析 +- 🤖AI 判断点:解析 Issue 列表,识别「无标签 / 无 assignee / 长期未更新」三类待处理 Issue,作为 Step2 输入;仓库为空或解析失败时告知用户并停止。 +- 本步全部只读,无需确认。 + +### Step 2: Issue 自动分类与分配(🤖AI 判断点 + 写入命令) + +- 调子 Skill:`gitlink-issue-triage` +- 纯命令前置(拉取可用元数据): + - `gitlink-cli workflow +triage --owner <owner> --repo <repo> --state open --limit 50 --lang zh-CN --format json` — 内置分诊规则给初步分类 + - `gitlink-cli issue +priorities --owner <owner> --repo <repo>` — 仓库可用优先级 + - `gitlink-cli issue +tags --owner <owner> --repo <repo>` — 仓库可用标签 + - `gitlink-cli issue +assigners --owner <owner> --repo <repo>` — 可分配人员 +- 🤖AI 判断点:对每个待处理 Issue,结合 title/body 与贡献者擅长领域,决定优先级、标签、责任人,组装批量参数。 +- ⚠️写入命令(执行前必须回显方案给用户确认): + - `gitlink-cli issue +series-update --owner <owner> --repo <repo> --ids <id1,id2,id3> --status open` — 批量更新状态 +- 确认话术:"共 N 条 Issue,预计修改状态/责任人,是否执行?" + +### Step 3: 社区动态汇总(🤖AI 判断点) + +- 调子 Skill:`gitlink-notification-digest`、`gitlink-contributor-insight` +- 纯命令: + - `gitlink-cli notification +list --limit 50 --format json` — 拉取通知(默认未读) + - `gitlink-cli repo +activity --owner <owner> --repo <repo>` — 仓库活跃曲线 + - `gitlink-cli user +heatmap --login <owner>` — 贡献热力图 + - `gitlink-cli user +stats --login <owner>` — 统计数据 +- 🤖AI 判断点: + - 通知去重归类(PR / Issue / Release / CI),提炼本周待办。 + - 识别「本周新晋贡献者」「Top 活跃」「需感谢的人」。 + - 输出结构化中间结果供 Step4 引用。 + +### Step 4: 撰写社区周报(🤖AI 判断点) + +- 🤖AI 判断点:汇总 Step1-3 结构化结果,按以下板块撰写 `WEEKLY-REPORT.md`: + 1. 本周数据概览(新增 Issue N / 合并 PR M / 新贡献者 K) + 2. 重点 Issue 进展 + 3. 贡献者榜单 + 4. 待跟进事项 + 5. 下周计划 +- 落地:工作目录生成 `WEEKLY-REPORT.md`(落盘前向用户展示大纲)。 +- 可选纯命令:`gitlink-cli pm +weekly --project <project_id>` — 补充官方周报数据(需项目 ID)。 + +### Step 5: Release 候选与发布(🤖AI 判断点 + 写入命令) + +- 调子 Skill:`gitlink-release-auto`、`gitlink-release-notes` +- 纯命令(采集发版素材): + - `gitlink-cli repo +tags --owner <owner> --repo <repo>` — 最近 tag,确定发版起点 + - `gitlink-cli repo +commits --owner <owner> --repo <repo>` — 自上 tag 以来提交 + - `gitlink-cli pr +list --owner <owner> --repo <repo> --state closed --limit 50` — 近期 PR(从中筛选已合并) + - `gitlink-cli repo +compare --owner <owner> --repo <repo> --base <last_tag> --head master` — 完整 diff 摘要 +- 🤖AI 判断点: + - 判断变更是否值得发版(仅文档微调则建议跳过)。 + - 按 conventional commits(feat/fix/perf/docs)分组,识别 BREAKING CHANGE。 + - 生成 Release Notes 正文,让用户确认版本号(major/minor/patch)。 +- ⚠️写入命令(发布前回显 tag、标题、正文预览): + - `gitlink-cli release +list --owner <owner> --repo <repo>` — 确认 tag 未被占用 + - `gitlink-cli release +create --owner <owner> --repo <repo> --tag <version> --name "<version>" --body "<Release Notes>" --target master` + +--- + +## 触发模式(可裁剪) + +本 Skill 支持全流程与单点触发: +- **全流程**:"跑一遍社区运营" → Step1→5 +- **仅 Issue 分类**:"把今天的新 Issue 分一下" → Step1→2 +- **仅周报**:"出本周社区周报" → Step1→3→4 +- **仅 Release**:"发个 v1.2.0" → Step5 + +Agent 第一步先与用户确认走哪种模式,避免多余写入。 + +--- + +## Agent 触发示例 + +**用户**:"把 gitlink-cli-demo 这周的社区运营跑一遍,顺便发个新版本。" + +**Agent**: +1. 确认 owner=jiangtx、repo=gitlink-cli-demo,版本号意向 → 全流程模式。 +2. Step1(纯命令):`issue +list --state open`、`export +contributors`、`export +issues`,解析出 12 条待分类 Issue。 +3. Step2(AI + 命令):`workflow +triage` + `issue +priorities/tags/assigners`,给出分类与责任人方案 → 回显 → 用户确认 → `issue +series-update` 落地。 +4. Step3(AI + 命令):`notification +list`、`repo +activity`、`user +heatmap` + contributor-insight,产出中间结果。 +5. Step4(AI):撰写 `WEEKLY-REPORT.md`,展示大纲 → 确认 → 落盘。 +6. Step5(AI + 命令):`repo +tags`、`pr +list --state closed`、`repo +compare`,识别 8 个合并 PR,判定值得发版 → 调 release-notes 生成正文 → 用户确认 v1.4.0 → `release +create` 发布。 +7. 输出执行清单:分诊 12 条、周报路径、Release URL。 diff --git a/skills/gitlink-compliance-check/SKILL.md b/skills/gitlink-compliance-check/SKILL.md new file mode 100644 index 0000000..2d8daa2 --- /dev/null +++ b/skills/gitlink-compliance-check/SKILL.md @@ -0,0 +1,171 @@ +--- +name: gitlink-compliance-check +version: 1.0.0 +description: "许可证合规检查:扫描仓库的许可证合规性和敏感信息泄露风险,生成合规报告。当用户需要检查许可证合规、扫描敏感信息、审计仓库安全性时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli repo --help" +--- + +# gitlink-compliance-check(许可证合规检查) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何仓库。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +扫描仓库的许可证合规性和安全风险: + +1. **许可证检查** — 确认仓库是否有开源许可证 +2. **文档完整性** — 检查 README、描述等基本文档 +3. **敏感信息扫描** — 从 PR/Issue 描述中检测可能的密钥泄露 +4. **CI/CD 安全** — 检查 DevOps 是否激活 +5. **合规报告** — 生成结构化的合规检查报告 + +--- + +## 工作流:许可证合规检查 + +### Step 1:获取仓库信息 + +```bash +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +``` + +检查以下字段: + +| 字段 | 合规维度 | +|------|----------| +| `license_id` | 许可证类型(null=无许可证) | +| `description` | 项目描述是否完整 | +| `empty` | 是否为空仓库 | +| `mirror` | 是否为镜像仓库 | +| `open_devops` | CI/CD 是否激活 | +| `default_branch` | 默认分支 | + +### Step 2:扫描 PR 和 Issue(敏感信息检测) + +```bash +gitlink-cli pr +list --owner <owner> --repo <repo> --format json +gitlink-cli issue +list --owner <owner> --repo <repo> --format json +``` + +在标题和描述中检测敏感信息: + +| 敏感模式 | 风险等级 | +|----------|----------| +| `sk-[a-zA-Z0-9]{20,}` | 🔴 API Key | +| `ghp_[a-zA-Z0-9]{36,}` | 🔴 GitHub Token | +| `password =` | 🔴 密码硬编码 | +| `AKIA[A-Z0-9]{16}` | 🔴 AWS Key | +| `-----BEGIN.*PRIVATE KEY-----` | 🔴 私钥 | +| IP 地址模式 | 🟡 内部 IP | + +### Step 3:合规评估 + +#### 3.1 许可证合规 + +| 状态 | 判定 | 评分 | +|------|------|------| +| ✅ 合规 | `license_id` 有值 | 10 | +| ⚠️ 风险 | `license_id`=null | 0 | + +#### 3.2 文档完整性 + +| 检查项 | 判定 | 评分 | +|--------|------|------| +| 项目描述 | `description` 非空 | 5 | +| 文档缺失 | `description` 为空或 null | 0 | + +#### 3.3 CI/CD 安全 + +| 检查项 | 判定 | 评分 | +|--------|------|------| +| DevOps | `open_devops`=true | 5 | +| 未激活 | `open_devops`=false | 0 | + +#### 3.4 综合评分(满分 20) + +| 维度 | 权重 | +|------|------| +| 许可证合规 | 10 | +| 文档完整性 | 5 | +| CI/CD 安全 | 5 | + +### Step 4:生成合规报告 + +--- + +## 输出模板 + +```markdown +# 📋 合规检查报告:{{full_name}} + +> 检查时间:{{当前时间}} +> 仓库:[{{full_name}}](https://www.gitlink.org.cn/{{owner}}/{{repo}}) + +--- + +## 一、综合评分 + +| 维度 | 状态 | 评分 | 说明 | +|------|------|------|------| +| 许可证合规 | {{license_status}} | {{license_score}}/10 | | +| 文档完整性 | {{doc_status}} | {{doc_score}}/5 | | +| CI/CD 安全 | {{ci_status}} | {{ci_score}}/5 | | +| **总分** | | **{{total_score}}/20** | | + +## 二、许可证详情 + +| 项目 | 内容 | +|------|------| +| 许可证 | {{license_info}} | +| 镜像仓库 | {{is_mirror}} | +| 建议 | {{license_advice}} | + +## 三、仓库安全 + +| 检查项 | 状态 | +|--------|------| +| CI/CD 激活 | {{open_devops_check}} | +| 仓库类型 | {{repo_type}} | +| 默认分支 | {{default_branch}} | + +## 四、敏感信息扫描 + +> 如无发现:✅ 本次扫描在 PR/Issue 标题和描述中未发现明显的敏感信息泄露。 +> 如有发现:列出风险等级、来源、类型。 + +## 五、改进建议 + +- 无许可证 → 建议添加开源许可证 +- DevOps 未激活 → 建议激活 CI/CD +- 无描述 → 建议补充项目描述 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| 许可证为 null | 标注"⚠️ 无许可证,存在合规风险" | +| 镜像仓库 | 标注"源平台许可证未同步" | +| 无 PR/Issue 数据 | 敏感信息扫描标注"数据不足" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **本 Skill 为纯只读操作** +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **敏感信息扫描仅覆盖 PR/Issue 标题和描述** +- ⚠️ **镜像仓库的许可证信息可能不准确** +- ⚠️ **版权合规最终应由法务确认** diff --git a/skills/gitlink-contributor-growth/SKILL.md b/skills/gitlink-contributor-growth/SKILL.md new file mode 100644 index 0000000..a4719dd --- /dev/null +++ b/skills/gitlink-contributor-growth/SKILL.md @@ -0,0 +1,154 @@ +--- +name: gitlink-contributor-growth +version: 1.0.0 +description: "贡献者成长体系编排:追踪 PR/Issue 活动后,AI 生成贡献排行、颁发徽章并产出成长报告,当用户想表彰贡献者、做月度/季度贡献回顾、激励社区活跃度时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + orchestrates: + - gitlink-contributor-insight + - gitlink-user + - gitlink-commit-quality + - gitlink-release-auto + cliHelp: "gitlink-cli --help" +--- + +# gitlink-contributor-growth(贡献者成长体系编排) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),认证/权限/API 注意事项。** +**CRITICAL — 所有写入操作(issue comment、repo create-file/update-file)前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 gitlink-cli。禁止用 gh(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — 徽章授予是对真实用户的公开评价,颁奖词须客观、正向、避免贬损,颁布前向用户复述最终文案。** + +--- + +## 工作流总览 + +```mermaid +flowchart TD + A[收到贡献者回顾需求] --> B{采集范围明确?} + B -->|否| B1[Step0 🤖AI澄清<br/>仓库/时间窗/维度] + B1 --> B + B -->|是| C[Step1 采集贡献数据<br/>export+contributors/prs/issues] + C --> D[Step2 🤖AI深度分析<br/>contributor-insight + commit-quality 打分] + D --> E[Step3 🤖AI生成排行榜<br/>设计4枚徽章 + 颁奖词] + E --> F{用户确认榜单?} + F -->|调整| E1[🤖AI调整权重/重排] --> F + F -->|确认| G[Step4 颁发徽章<br/>issue+comment 授予颁奖词] + G --> H[Step5 🤖AI生成成长报告<br/>CONTRIBUTION-LEADERBOARD.md] + H --> I[Step6 🤖AI总结输出] +``` + +--- + +## 编排的子 Skill + +| 子 Skill | 职责 | 调用时机 | +|---|---|---| +| gitlink-contributor-insight | 贡献者活跃度、贡献类型、影响力画像 | Step2 逐人画像 | +| gitlink-user | 用户资料、heatmap、stats 核验 | Step2 候选人细化、Step4 颁奖词引用 | +| gitlink-commit-quality | 提交/PR 质量评估 | Step2 引入质量维度,避免只看数量 | +| gitlink-release-auto | 结构化亮点提炼 | Step5 报告"本期亮点"段落 | + +--- + +## 详细步骤 + +### Step 0: 🤖AI 澄清采集范围(AI 判断点) + +- 🤖AI 判断点:向用户确认四要素: + 1. **目标仓库**(owner/repo)—— 若只给仓库名,用 `gitlink-cli search +repos -k <name>` 反查。 + 2. **时间窗**(周/月/季度或起止日期)。 + 3. **榜单维度**(默认综合榜;可选修复/文档/新人榜)。 + 4. **是否公开发榜**(决定 Step4 是否评论、Step5 是否提交文件)。 +- 信息齐全则跳过本步。 + +### Step 1: 采集贡献数据(纯命令) + +优先用 `export` 模块(稳定、批量、结构化): +- `gitlink-cli export +contributors --owner <owner> --repo <repo> --format json --output contributors.json` +- `gitlink-cli export +prs --owner <owner> --repo <repo> --format json --output prs.json` +- `gitlink-cli export +issues --owner <owner> --repo <repo> --format json --output issues.json` +- 补充核验(字段不全时): + - `gitlink-cli repo +contributors --owner <owner> --repo <repo>` + - `gitlink-cli repo +activity --owner <owner> --repo <repo>` +- 🤖AI 判断点:读取三个 JSON,按 login 聚合 PR/Issue/评论/合并数;识别新人(首次贡献在时间窗内)与老贡献者;取 Top N(默认 15)候选。 + +### Step 2: 🤖AI 深度分析(AI 判断点 + 子 Skill) + +- 调 `gitlink-contributor-insight`:每人活跃度、贡献类型(feature/bugfix/docs/refactor)、影响力。 +- 调 `gitlink-commit-quality`:代表性 PR 质量评估(规范、测试、聚焦度),0-100 质量分。 +- 调 `gitlink-user` 核验 Top 候选: + - `gitlink-cli user +info --login <login>` + - `gitlink-cli user +heatmap --login <login>` + - `gitlink-cli user +stats --login <login>` +- 🤖AI 判断点:综合打分(透明可解释): + - `综合分 = 0.4*活跃度 + 0.3*质量分 + 0.2*影响力 + 0.1*趋势` + - 分项分写入本地草稿 `scoring.md`(不提交),标注是否新人。 + +### Step 3: 🤖AI 生成排行榜 + 设计徽章(AI 判断点) + +- 🤖AI 判断点:徽章体系(可在 Step0 自定义,默认): + - 🏆 **本周之星 / 月度之星**:综合分第一(时间窗>1 月改"季度之星") + - 🔧 **修复达人**:bugfix 类 PR 合并最多且质量分达标 + - 📖 **文档能手**:docs 类贡献最多 + - 🌱 **新人突破奖**:时间窗内首次贡献且进 Top N 的新人 +- 🤖AI 判断点:为每位获奖者撰颁奖词草稿——客观陈述(X PR、Y Issue、质量分 Z)、正向具体、≤120 字、引用真实数据。 +- ⚠️强制确认:把"榜单 + 徽章归属 + 颁奖词全文"复述给用户,确认或调整后才进入 Step4。 + +### Step 4: 颁发徽章(纯命令,须先经 Step3 确认) + +在仓库"欢迎 Issue / 贡献者公告 Issue"下发表评论授予颁奖词。若无,先建一个: +- 查找: + - `gitlink-cli issue +list --owner <owner> --repo <repo> --state open --limit 50` +- 创建(若无): + - `gitlink-cli issue +create --owner <owner> --repo <repo> --title "贡献者成长榜 · <时间窗>" --body "<榜单概述>"` +- 颁发(每位获奖者一条 comment): + - `gitlink-cli issue +comment --owner <owner> --repo <repo> --number <issue_number> --body "<🆔 @login | 🏅 徽章 | 📝 颁奖词>"` +- ⚠️执行前:(1) 已获 Step3 确认;(2) 每条 comment 全文再复述一次;(3) 用户点头后批量发出。 +- 🤖AI 判断点:用户若要"静默颁奖"则跳过本步,仅在 Step5 报告列出。 + +### Step 5: 🤖AI 生成成长报告(AI 判断点 + 写入命令) + +- 🤖AI 判断点:撰写 `CONTRIBUTION-LEADERBOARD.md`: + 1. 头部:时间窗、仓库、覆盖人数、总 PR/Issue 数 + 2. 本期亮点:调 release-auto 提炼 3-5 条高光时刻 + 3. 完整排行榜:rank / login / 综合分 / 分项 / 徽章 + 4. 徽章授予记录:颁奖词全文 + 5. 致谢与展望 +- ⚠️写入命令(确认目标分支与路径后): + - `gitlink-cli repo +create-file --owner <owner> --repo <repo> --filepath CONTRIBUTION-LEADERBOARD.md --content "<报告>" --message "docs: 更新贡献者成长榜"` + - 文件已存在则用 `repo +update-file`(同参数)。 +- 🤖AI 判断点:用户若要本地落盘不入库,则写本地文件,跳过 CLI 写入。 + +### Step 6: 🤖AI 总结输出(AI 判断点) + +- 🤖AI 判断点:汇报执行结果: + - 采集覆盖:人数、PR 数、Issue 数 + - 榜单:Top N 与徽章归属 + - 颁奖:在哪个 Issue 发了多少条 comment(或说明静默) + - 报告:`CONTRIBUTION-LEADERBOARD.md` 最终位置 + - 后续建议:是否周期执行、是否补 `release +create` 发版公告 + +--- + +## Agent 触发示例 + +**用户**:"帮我给 myorg/core-repo 这个仓库做上个月的贡献者回顾,颁个奖。" + +**Agent**: +1. Step0(🤖AI):确认 owner=myorg、repo=core-repo、时间窗=上月、综合榜+三枚徽章、公开颁榜 → 复述确认。 +2. Step1(纯命令):并行 `export +contributors/prs/issues`,按 login 聚合,取 Top 15 候选。 +3. Step2(🤖AI):调 contributor-insight 画像、commit-quality 打分、Top 5 用 `user +info/heatmap/stats` 核验;算综合分写 `scoring.md`。 +4. Step3(🤖AI):生成榜单与四枚徽章(🏆月度之星/🔧修复达人/📖文档能手/🌱新人突破),写颁奖词 → 复述确认。 +5. Step4(纯命令):用户确认后 `issue +list` 找欢迎 Issue,无则 `issue +create`;对每位获奖者 `issue +comment` 颁奖(每条再次复述后发出)。 +6. Step5(🤖AI+纯命令):撰写 `CONTRIBUTION-LEADERBOARD.md`,`repo +update-file`(或 +create-file)写入仓库。 +7. Step6(🤖AI):汇报覆盖 23 人/47 PR/81 Issue,Top 3 榜单,颁发 4 枚徽章于 Issue #128,报告位于仓库根。 + +--- + +## 注意事项 + +- **只读 vs 写**:Step0-2 全只读;Step3 颁奖词须确认;Step4 comment、Step5 create-file/update-file 为写入须确认。 +- **颁奖词红线**:客观、正向、避免贬损,颁布前复述。 +- **不替代子 Skill**:贡献者深度分析仍由 contributor-insight/user/commit-quality 各自负责。 diff --git a/skills/gitlink-contributor-insight/EXAMPLES.md b/skills/gitlink-contributor-insight/EXAMPLES.md index b0fb4fb..df2b347 100644 --- a/skills/gitlink-contributor-insight/EXAMPLES.md +++ b/skills/gitlink-contributor-insight/EXAMPLES.md @@ -31,16 +31,16 @@ gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json # → 0 个 Issue ``` -### 不可用命令确认 +### 命令可用性(执行时状态 vs 当前状态) -| 命令 | 结果 | -|------|------| -| `gitlink-cli repo +contributors` | 命令不存在,返回 repo 帮助文本 | -| `gitlink-cli user +heatmap` | 命令不存在(user 子命令仅 `+info` / `+me`) | -| `gitlink-cli user +stats` | 命令不存在 | -| `gitlink-cli user +trends` | 命令不存在 | -| `gitlink-cli api GET "/api/v1/repos/.../contributors"` | 返回 HTML 页面,非 JSON | -| `gitlink-cli api GET "/api/v1/users/.../heatmap"` | 返回 HTML 页面,非 JSON | +| 命令 | 执行时 (2026-06-03) | 当前状态 | +|------|---------------------|----------| +| `gitlink-cli repo +contributors` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +heatmap` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +stats` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +trends` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli repo +activity` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli repo +languages` | ❌ 命令不存在 | ✅ 已可用 | ### 关键发现 @@ -215,7 +215,7 @@ Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人 | `user +heatmap` 不可用 | 命令不存在 | user 仅 `+info`/`+me` | 从 PR 时间戳推算活跃天数 | | `user +stats` 不可用 | 命令不存在 | 同上 | 从 `pr +list` 聚合 PR/Issue 数 | | `user +trends` 不可用 | 命令不存在 | 同上 | 从 PR 按日聚合判断趋势 | -| Raw API 返回 HTML | `api GET` 返回 HTML | 非 JSON 响应 | 仅使用 Shortcut 命令 | +| Raw API 返回 HTML | `api GET` 返回 HTML | 非 JSON 响应 | 全部命令已转为 Shortcut | | 项目 < 30 天 | PR 时间跨度 < 30 天 | 全部 PR 在近期 | 放宽分级标准,标注"早期阶段" | | 贡献者 ≤ 2 人 | `contributor_users_count` ≤ 2 | Bus Factor 极低 | 报告标注风险 + 提供吸引新人建议 | | PR 数为 0 | `pr +list` 空数组 | `issues: []` | 标注"仓库暂无 PR 数据" | @@ -230,7 +230,7 @@ Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人 | CLI 版本 | 贡献者分析可用命令 | 缺失命令 | |----------|-------------------|----------| | 0.1.18 | `repo +info`, `pr +list`, `issue +list`, `user +info` | `repo +contributors`, `user +heatmap`, `user +stats`, `user +trends` | -| 未来版本 | 可能新增 `user +heatmap` 等 | — | +| 当前版本 | `repo +contributors`, `user +heatmap`, `user +stats`, `user +trends` 等 | — | 当 CLI 版本更新后,重新验证可用命令: ```bash diff --git a/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md index 57b4df5..2c832a9 100644 --- a/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md +++ b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md @@ -130,16 +130,16 @@ PR 详细列表: } ``` -### 5. 不可用的命令 +### 5. 命令可用性(执行时状态 vs 当前状态) -| 命令 | 结果 | -|------|------| -| `gitlink-cli repo +contributors` | 命令不存在,返回 repo 帮助文本 | -| `gitlink-cli user +heatmap` | 命令不存在(user 仅 `+info` / `+me`) | -| `gitlink-cli user +stats` | 命令不存在 | -| `gitlink-cli user +trends` | 命令不存在 | -| `gitlink-cli api GET "/api/v1/repos/.../contributors"` | 返回 HTML 页面,非 JSON | -| `gitlink-cli api GET "/api/v1/users/.../heatmap"` | 返回 HTML 页面,非 JSON | +| 命令 | 执行时 (2026-06-03) | 当前状态 | +|------|---------------------|----------| +| `gitlink-cli repo +contributors` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +heatmap` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +stats` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli user +trends` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli repo +activity` | ❌ 命令不存在 | ✅ 已可用 | +| `gitlink-cli repo +languages` | ❌ 命令不存在 | ✅ 已可用 | --- diff --git a/skills/gitlink-multi-repo-ops/SKILL.md b/skills/gitlink-multi-repo-ops/SKILL.md new file mode 100644 index 0000000..4365817 --- /dev/null +++ b/skills/gitlink-multi-repo-ops/SKILL.md @@ -0,0 +1,147 @@ +--- +name: gitlink-multi-repo-ops +version: 1.0.0 +description: "多仓库协同编排:跨多个 GitLink 仓库的统一 Issue 追踪、PR 状态看板与 Release 协调发布,当用户需要同时管理/对比/协调多个仓库时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + orchestrates: + - gitlink-issue + - gitlink-pr + - gitlink-release + - gitlink-insight + - gitlink-workflow + cliHelp: "gitlink-cli --help" +--- + +# gitlink-multi-repo-ops(多仓库协同编排) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),认证/权限/API 注意事项。** +**CRITICAL — 所有写入/删除操作(批量 release create、issue batch-close)前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 gitlink-cli。禁止用 gh(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — 编排型 Skill 只做"导演",不重写子 Skill 内部逻辑。** + +--- + +## 工作流总览 + +```mermaid +flowchart TD + U[用户自然语言输入<br/>含仓库名/组织/清单] --> S1[Step1 🤖AI解析仓库清单] + S1 --> S2[Step2 跨仓库数据采集<br/>循环 issue/pr/release list] + S2 --> S2J{数据齐全?} + S2J -->|否| S2 + S2J -->|是| S3[Step3 🤖AI跨仓分析<br/>workflow+repo-report / +health] + S3 --> S4[Step4 🤖AI生成协同看板<br/>MULTI-REPO-DASHBOARD.md] + S4 --> S5[Step5 🤖AI Release依赖判断<br/>拓扑排序] + S5 --> CONF{用户确认发布计划?} + CONF -->|修改| S5 + CONF -->|同意| S6[Step6 Release协调发布<br/>release+create 串行] + S6 --> DONE[看板 + 发布报告] +``` + +--- + +## 编排的子 Skill + +| 子 Skill | 职责 | 调用时机 | +|---|---|---| +| gitlink-issue | 单仓 Issue 的 list/view/close | Step2 采集、Step6 发布前阻断检查 | +| gitlink-pr | 单仓 PR 的 list/view/merge/check-merge | Step2 采集、Step6 发布前阻断检查 | +| gitlink-release | 单仓 Release 的 list/create/view | Step2 采集、Step6 协调发布 | +| gitlink-insight | 仓库画像、贡献者、活跃度 | Step3 跨仓分析 | +| gitlink-workflow | repo-report / triage / health 汇总 | Step3 单仓报告、Step4 看板合成 | + +--- + +## 详细步骤 + +### Step 1: 🤖AI 解析仓库清单(AI 判断点) + +- 输入:用户自然语言(如"看看 acme 组织下的 frontend、backend、infra 三个库")。 +- 🤖AI 判断点:抽取规范化的 `owner/repo` 列表,去重、补全大小写。若只给组织名,先拉候选再请用户确认。 +- 纯命令(仅当需枚举候选时): + - `gitlink-cli org +list --owner <org>` + - `gitlink-cli repo +list --owner <org> --limit 50` +- 🤖AI 判断点:清单为空或歧义时**必须暂停确认**,不得擅自推断。 +- 产出:内部 `repo_list` 数组,供后续循环。 + +### Step 2: 跨仓库数据采集(纯命令,循环执行) + +对 `repo_list` 每个 repo 循环执行只读命令,结果按仓库归档: +- `gitlink-cli issue +list --owner <owner> --repo <repo> --state open --limit 50` +- `gitlink-cli pr +list --owner <owner> --repo <repo> --state open --limit 50` +- `gitlink-cli release +list --owner <owner> --repo <repo> --limit 10` +- `gitlink-cli milestone +list --owner <owner> --repo <repo>` +- `gitlink-cli repo +info --owner <owner> --repo <repo>` +- 🤖AI 判断点:某仓库采集失败(404/无权限)记入 errors[],**不中断流程**,继续下一个,最终看板标注"采集失败"。 +- 全部 GET,安全无需确认。 + +### Step 3: 🤖AI 跨仓统一分析(AI 判断点) + +- 纯命令(逐仓库): + - `gitlink-cli workflow +repo-report --repository <owner>/<repo>` + - `gitlink-cli workflow +health --repository <owner>/<repo>` + - `gitlink-cli workflow +triage --owner <owner> --repo <repo> --state open`(若 issue 堆积) +- 🤖AI 判断点: + - 对比各仓"未关闭 Issue / 未合并 PR / 距上次 Release 时长",找瓶颈仓库与风险仓库。 + - 识别跨仓关联 Issue(标题/标签相似)、共享贡献者、依赖同一里程碑的发布计划。 + - 汇总每仓 P0/P1 issue 与阻塞 PR,形成优先级矩阵。 + +### Step 4: 🤖AI 生成协同看板(AI 判断点) + +- 纯命令(可选,补充活跃度): + - `gitlink-cli repo +activity --owner <owner> --repo <repo>` + - `gitlink-cli repo +contributors --owner <owner> --repo <repo> --limit 10` +- 🤖AI 判断点:撰写 `MULTI-REPO-DASHBOARD.md`,结构: + 1. 仓库总览表(仓库 | 开放 Issue | 开放 PR | 最新 Release | 健康分) + 2. 跨仓 Issue 看板(按优先级/标签归并) + 3. 跨仓 PR 看板(可合并 / 阻塞 / 冲突) + 4. Release 协调时间线 + 5. 风险提示 +- 🤖AI 判断点:若用户提及"导出": + - `gitlink-cli export +issues --owner <owner> --repo <repo> --output issues.csv` + - `gitlink-cli export +prs --owner <owner> --repo <repo> --output prs.csv` + +### Step 5: 🤖AI Release 依赖判断(AI 判断点,关键决策) + +- 纯命令(核对可合并性与现有 release): + - `gitlink-cli pr +check-merge --owner <owner> --repo <repo> --number <pr>` — 发布分支 PR 冲突预检 + - `gitlink-cli release +view --owner <owner> --repo <repo> --id <version_id>` +- 🤖AI 判断点: + - **依赖拓扑排序**:backend 依赖 infra、frontend 依赖 backend → 发布顺序 `infra → backend → frontend`。 + - **发布门禁**:任一仓库有阻断级未合并 PR 或 P0 issue,先警告,不得自动跳过。 + - 生成"建议发布顺序"草案(每仓 tag、notes 要点、顺序)。 +- ⚠️强制确认:暂停并向用户确认发布计划与顺序,未确认严禁进入 Step6。 + +### Step 6: Release 协调发布(纯命令,需用户确认) + +按拓扑顺序逐仓库串行执行(前一个失败则停止,避免半发布): +- `gitlink-cli release +create --owner <owner> --repo <repo> --tag <vX.Y.Z> --name "<标题>" --body "<notes>" --target <branch>` +- 每仓发布后核对: + - `gitlink-cli release +view --owner <owner> --repo <repo> --id <version_id>` +- 🤖AI 判断点:中间某仓失败,记录已成功与失败仓,回滚交由用户(不擅自 `release +delete`)。 +- 可选(用户明确要求清理"已发布修复"的 issue,再次确认后): + - `gitlink-cli issue +batch-close --owner <owner> --repo <repo> --numbers <n,n>` + +--- + +## Agent 触发示例 + +**用户**:"帮我盘点 acme 组织的 frontend、backend、infra 三个仓库的进度,顺便把该发的版本协调发一下。" + +**Agent**: +1. Step1(🤖AI):提取 `repo_list = [acme/frontend, acme/backend, acme/infra]`。 +2. Step2(纯命令):循环三仓跑 `issue +list`、`pr +list`、`release +list`、`milestone +list`、`repo +info`,落盘 `data/`。 +3. Step3(🤖AI):逐仓 `workflow +repo-report`、`workflow +health`;对比得出"backend PR #142 阻塞 frontend,infra 已 4 个月未发版"。 +4. Step4(🤖AI):生成 `MULTI-REPO-DASHBOARD.md`,含总览表、PR 看板、Release 时间线、风险。 +5. Step5(🤖AI):判断依赖顺序 `infra(v2.1.0) → backend(v3.4.0) → frontend(v1.9.0)`;`pr +check-merge` 确认 backend 发布分支无冲突 → 暂停确认。 +6. 用户批准后 Step6(纯命令):按顺序 `release +create` 三仓,每发一个即 `release +view` 核对,汇总发布结果。 + +--- + +## 注意事项 + +- **只读 vs 写**:Step1-4 全只读可自由执行;Step5 决策与 Step6 写操作必须显式确认。 +- **幂等性**:采集失败不重试到死循环;发布失败不自动回滚。 +- **不替代子 Skill**:单仓深度操作细节仍由 issue/pr/release/insight/workflow 各自负责。 diff --git a/skills/gitlink-notification-digest/EXAMPLES.md b/skills/gitlink-notification-digest/EXAMPLES.md index 7c52285..70349cd 100644 --- a/skills/gitlink-notification-digest/EXAMPLES.md +++ b/skills/gitlink-notification-digest/EXAMPLES.md @@ -14,11 +14,11 @@ gitlink-cli auth status # → Logged in as lindiwen23 # Step 2: 获取未读通知(status=1) -gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json +gitlink-cli notification +list --format json # → 7 条未读,unread_notification=7, unread_atme=0 # Step 3: 获取已读通知(用于趋势分析和回顾) -gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json +gitlink-cli notification +list --all --format json # → 21 条已读 # Step 4: 分类统计、生成摘要报告 @@ -176,12 +176,10 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" ### 经验总结 -1. **`gitlink-cli notification` 命令不存在**:GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API +1. **`gitlink-cli notification` 命令已可用**:使用 `notification +list` `+read` `+read-all` `+watch` 管理通知 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),匹配时注意 +3. **没有批量已读 API**:标记已读需逐条调用 `notification +read --id <id>` +4. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 --- @@ -195,12 +193,8 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" ``` 工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件 工具调用 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 ← 获取全部(总计统计) +工具调用 3: Bash → gitlink-cli notification +list --format json ← 获取未读 +工具调用 4: Bash → gitlink-cli notification +list --all --format json ← 获取已读(趋势分析) ``` ### Agent 决策过程 @@ -283,7 +277,7 @@ Agent **正确遵循了 skill v2.0.0 的工作流**: | 场景 | 检测方式 | 处理 | |------|----------|------| -| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` | +| `notification +list` 返回空 | 可能无未读通知 | 使用 `notification +list --all` 查看全部 | | API 返回 HTML 而非 JSON | 响应以 `<!doctype html>` 开头 | 去掉路径前导 `/` 重试 | | 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` | | 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 | @@ -293,14 +287,14 @@ Agent **正确遵循了 skill v2.0.0 的工作流**: ## 版本兼容性说明 -本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更: +本 skill v3.0.0 基于 `gitlink-cli` 最新版本编写。关键变更: -| 版本 | `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` | +| 版本 | 通知获取 | 标记已读 | 关注 | +|------|----------|----------|------| +| v1.0-v2.0 | Raw API `GET users/{owner}/messages.json` | `POST users/{owner}/messages/{id}/read` | 无 | +| v3.0.0 | `notification +list` | `notification +read` / `+read-all` | `notification +watch` | 当 CLI 版本更新后,重新验证可用命令: ```bash -gitlink-cli --help +gitlink-cli notification --help ``` diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index 72cf9df..b069a71 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-notification-digest -version: 2.0.0 +version: 3.0.0 description: "通知摘要:汇总 GitLink 通知并按类型分类,生成通知摘要报告,支持批量标记已读。当用户需要查看通知摘要、整理通知、清理未读通知时触发。" metadata: requires: @@ -20,9 +20,9 @@ metadata: ## 功能概述 -帮助用户高效管理 GitLink 通知(GitLink 平台称为「消息」): +帮助用户高效管理 GitLink 通知: -1. **通知列表** — 获取所有未读通知 +1. **通知列表** — 使用 `notification +list` 获取通知 2. **自动分类** — 按 `source` 字段分类(Issue/PR/系统等) 3. **优先级判断** — 识别需要立即处理的通知 4. **批量操作** — 支持标记已读(需确认) @@ -30,93 +30,52 @@ metadata: --- -## ⚠️ 关键注意事项 - -### CLI 路径处理 Bug - -**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。 +## 可用的 notification 命令 ```bash -# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/... -gitlink-cli api GET /users/me +# 列出通知 +gitlink-cli notification +list [--all] [--participating] [--page 1] [--limit 20] -# ✅ 正确 — 去掉前导 / -gitlink-cli api GET "users/{owner}/messages.json" +# 标记单条已读 +gitlink-cli notification +read --id <notification_id> + +# 标记全部已读 +gitlink-cli notification +read-all + +# 关注/取消关注仓库通知 +gitlink-cli notification +watch --owner <owner> --repo <repo> +gitlink-cli notification +watch --owner <owner> --repo <repo> --unwatch ``` -### 术语对照 - -GitLink 平台用「**消息**」(messages)而不是「通知」(notifications)。API 端点和字段均使用 `messages`。 - --- ## 工作流:通知摘要 ### Step 1:获取通知列表 -使用 Raw API 调用 `/api/users/{owner}/messages.json`: +使用 `notification +list` 命令: ```bash -# 获取未读通知(status=1 表示未读,2 表示已读) -gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json +# 获取未读通知 +gitlink-cli notification +list --format json -# 获取全部通知(含已读) -gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json +# 获取所有通知(含已读) +gitlink-cli notification +list --all --format json + +# 仅参与的通知 +gitlink-cli notification +list --participating --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 api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json +gitlink-cli notification +list --page 2 --limit 20 --format json ``` -**参数说明:** - -| 参数 | 位置 | 说明 | -|------|------|------| -| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) | -| `status` | Query | 1=未读,2=已读,不传=全部 | -| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 | -| `page` | Query | 页码(默认 1) | -| `limit` | Query | 每页条数(默认 20) | - -**响应结构:** - -```json -{ - "total_count": 28, - "type": "", - "unread_notification": 7, - "unread_atme": 0, - "messages": [ - { - "id": 740214, - "status": 1, - "content": "jiangtx在 <b>jiangtx/gitlink-cli</b> 提交了一个合并请求:<b>label 模块新建</b>", - "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15347", - "source": "ProjectPullRequest", - "created_at": "2026-06-03 00:27:37", - "time_ago": "10小时前", - "type": "notification", - "sender": { - "id": 113, - "type": "User", - "name": "jiangtx", - "login": "jiangtx", - "image_url": "..." - } - } - ] -} -``` +**响应结构:** 与 GitLink 平台「消息」API 返回结构一致。 **提取字段:** - `id` — 消息 ID(用于标记已读) - `content` — HTML 格式的通知内容 - `source` — 通知来源类型(枚举值,见下方分类表) -- `notification_url` — 跳转链接,可从中解析仓库(提取 URL 中的 `/owner/repo/` 段) +- `notification_url` — 跳转链接,可从中解析仓库 - `created_at` — 通知时间(格式 `YYYY-MM-DD HH:mm:ss`) - `status` — 1=未读,2=已读 - `type` — `notification` 或 `atme` @@ -194,16 +153,18 @@ gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&li ```bash # 标记单条已读 -gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json +gitlink-cli notification +read --id <notification_id> -# 批量标记已读 — 逐条调用,GitLink 暂无批量已读 API +# 全部标记已读 +gitlink-cli notification +read-all + +# 批量标记 — 逐条调用 for id in <id1> <id2> <id3>; do - gitlink-cli api POST "users/{owner}/messages/$id/read" --format json + gitlink-cli notification +read --id $id done ``` > ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。 -> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。 --- @@ -283,7 +244,7 @@ done - 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 如需标记 P3 通知为已读,我可以逐条执行: -`gitlink-cli api POST "users/{owner}/messages/{id}/read"` +`gitlink-cli notification +read --id <id>` ``` --- @@ -295,7 +256,7 @@ done | 无未读通知 | 输出"🎉 所有通知已处理完毕" | | 通知数量 > 50 | 分页获取(page 1/2/3),优先分析最近 50 条 | | API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 | -| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 | +| 通知数量 > 20 | 分页获取(--page 2,3),优先分析最近 50 条 | | 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 | --- @@ -305,7 +266,6 @@ done - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **标记已读为写操作**,执行前必须确认用户意图 - ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读 -- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**(CLI Bug) - ⚠️ **GitLink 用「消息(messages)」而非「通知(notifications)」** - ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配 -- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"` +- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2` diff --git a/skills/gitlink-onboarding/SKILL.md b/skills/gitlink-onboarding/SKILL.md index 667f612..f38dab9 100644 --- a/skills/gitlink-onboarding/SKILL.md +++ b/skills/gitlink-onboarding/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-onboarding -version: 1.0.0 -description: "新人入门引导:帮助新贡献者发现适合入门的 Issue、了解项目贡献流程。当用户想参与项目贡献但不知从何入手、寻找入门任务、或询问如何开始贡献代码时触发。" +version: 1.1.0 +description: "新人入门引导:帮助新贡献者发现适合入门的 Issue,自动生成引导评论降低参与门槛。当用户想参与项目贡献但不知从何入手、寻找入门任务、或询问如何开始贡献代码时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -104,9 +104,16 @@ gitlink-cli issue +view --owner <owner> --repo <repo> --number <project_issues_i - `comment_journals_count` — 是否有讨论历史(有讨论 = 需求更明确) - `start_date` / `due_date` — 是否有时间限制 -### Step 5:生成新人引导报告 +### Step 5:生成引导评论(v1.1 新增) -将所有信息组织为以下格式输出。 +为每个推荐的入门 Issue 生成一段可直接在 GitLink Issue 下发布的引导评论。评论内容包括: +- 🎉 欢迎语 + 适合新手说明 +- 📋 建议步骤(认领、Fork、Clone、分支、提交、PR) +- 💡 提示语(鼓励提问、提供帮助) + +### Step 6:生成新人引导报告 + +将所有信息(含引导评论)按以下格式输出。 --- diff --git a/skills/gitlink-pm/SKILL.md b/skills/gitlink-pm/SKILL.md index f5333ed..8356655 100644 --- a/skills/gitlink-pm/SKILL.md +++ b/skills/gitlink-pm/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-pm -version: 1.0.0 +version: 1.1.0 description: "项目管理(PM):Sprint、看板、周报等项目管理功能。当用户需要使用 GitLink PM 功能时触发。" metadata: requires: @@ -14,33 +14,31 @@ metadata: **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** -GitLink PM 模块提供敏捷项目管理能力,目前通过 Raw API 访问。 +GitLink PM 模块提供敏捷项目管理能力,通过 `pm` Shortcut 命令访问。 -## API 端点 - -> 前缀:`/api/pm` +## 可用命令 ```bash # 看板 -gitlink-cli api GET /pm/dashboards --query 'project_id=123' +gitlink-cli pm +dashboards --project <project_id> # Sprint Issue 列表 -gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' +gitlink-cli pm +sprints --project <project_id> # 周报 -gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' +gitlink-cli pm +weekly --project <project_id> # Issue 标签 -gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +gitlink-cli pm +tags --project <project_id> # 流水线 -gitlink-cli api GET /pm/pipelines --query 'project_id=123' +gitlink-cli pm +pipelines --project <project_id> # Action 运行记录 -gitlink-cli api GET /pm/action_runs --query 'project_id=123' +gitlink-cli pm +runs --project <project_id> ``` ## 注意事项 -- PM 接口需要项目 ID(`project_id`),可通过 `repo +info` 获取 +- PM 命令使用 `--project` flag 指定项目 ID,可通过 `repo +info` 获取 - PM 功能需要项目开启 PM 模块 diff --git a/skills/gitlink-pr-gate/SKILL.md b/skills/gitlink-pr-gate/SKILL.md new file mode 100644 index 0000000..ba78de3 --- /dev/null +++ b/skills/gitlink-pr-gate/SKILL.md @@ -0,0 +1,152 @@ +--- +name: gitlink-pr-gate +version: 1.0.0 +description: "代码质量看门人编排:PR 提交后,AI 自动审查代码 → 跑/核对 CI → 汇总结构化 Review 评论 → 质量达标自动合并,当用户需要审查 PR、给 PR 做 Review、把关合并质量时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + orchestrates: + - gitlink-code-review + - gitlink-commit-quality + - gitlink-ci-health + - gitlink-pr + cliHelp: "gitlink-cli pr --help" +--- + +# gitlink-pr-gate(代码质量看门人编排) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),认证/权限/API 注意事项。** +**CRITICAL — 所有写入操作(pr +comment、pr +merge)前,务必先确认用户意图。** +**CRITICAL — 自动合并是高风险写操作,必须把审查结论完整复述给用户,确认后才合并。** +**CRITICAL — GitLink 操作只能用 gitlink-cli。禁止用 gh(GitHub CLI)操作 GitLink 资源。** + +--- + +## 工作流总览 + +```mermaid +flowchart TD + A[用户: 审查某个 PR] --> S0[Step0 🤖AI解析审查目标<br/>确定 PR 编号] + S0 --> S1[Step1 采集 PR 变更<br/>pr+diff / +files / +commits] + S1 --> S2[Step2 🤖AI代码审查<br/>调 code-review + commit-quality] + S2 --> S3[Step3 CI 检查<br/>调 ci-health / ci+builds] + S3 --> S4[Step4 🤖AI汇总评审<br/>生成结构化 Review 评论] + S4 --> CONF1{用户确认评论?} + CONF1 -->|是| S5[pr+comment 发布评论] + CONF1 -->|修改| S4 + S5 --> S6[Step5 🤖AI质量达标判断] + S6 --> CONF2{达标且用户确认合并?} + CONF2 -->|是| S7[pr+merge 自动合并] + CONF2 -->|否| S8[列出需修改项,不合并] + S7 --> DONE[完成] + S8 --> DONE +``` + +--- + +## 编排的子 Skill + +| 子 Skill | 职责 | 调用时机 | +|---|---|---| +| gitlink-code-review | AI 语义代码审查(逻辑/安全/性能/可读性) | Step2 核心 | +| gitlink-commit-quality | 提交规范检查(Conventional Commits、PR 描述) | Step2 规范 | +| gitlink-ci-health | CI 构建状态与成功率 | Step3 CI | +| gitlink-pr | PR 的 diff/files/commits/comment/merge/check-merge | Step1、Step4、Step5 | + +--- + +## 详细步骤 + +### Step 0: 🤖AI 解析审查目标(AI 判断点) + +- 🤖AI 判断点:确认审查对象: + - 用户给了 PR 编号/URL → 直接用 + - 用户说"最近的 PR"/"待审 PR" → `gitlink-cli pr +list --owner <owner> --repo <repo> --state open` 找出候选,列给用户确认 +- 纯命令: + - `gitlink-cli pr +view --owner <owner> --repo <repo> --number <pr_number>` — 确认 PR 存在、拿基本信息(标题、分支、作者) + +### Step 1: 采集 PR 变更(纯命令) + +- 纯命令(拉取审查素材): + - `gitlink-cli pr +diff --owner <owner> --repo <repo> --number <pr_number>` — 完整 diff + - `gitlink-cli pr +files --owner <owner> --repo <repo> --number <pr_number>` — 变更文件清单 + - `gitlink-cli pr +commits --owner <owner> --repo <repo> --number <pr_number>` — 提交历史 +- 本步全部只读,无需确认。 + +### Step 2: 🤖AI 代码审查(AI 判断点 + 子 Skill) + +- 调 `gitlink-code-review`:对 diff 做语义分析,找潜在问题—— + - 逻辑错误、边界条件、空指针、资源泄漏 + - 安全隐患(注入、鉴权、敏感信息) + - 性能问题、可读性、命名 +- 调 `gitlink-commit-quality`:检查提交规范—— + - Conventional Commits(feat/fix/docs/...) + - PR 描述完整性、分支命名 +- 🤖AI 判断点:把审查结果分级: + - 🔴 阻断问题(必须改才能合并) + - 🟡 建议改进(可改可不改) + - 🟢 优点(值得肯定的写法) +- 纯命令(可选,辅助判断可合并性): + - `gitlink-cli pr +check-merge --owner <owner> --repo <repo> --number <pr_number>` — 冲突预检 + +### Step 3: CI 检查(纯命令 + 子 Skill) + +- 调 `gitlink-ci-health`:拉取该 PR 相关构建状态。 +- 纯命令: + - `gitlink-cli ci +builds --owner <owner> --repo <repo> --limit 10` — 最近构建列表 + - `gitlink-cli ci +logs --owner <owner> --repo <repo> --number <build_id>` — 失败时查日志 +- 🤖AI 判断点: + - CI 全绿 → 客观质量达标 + - CI 失败 → 标记阻断,合并前必须修复 + - 无 CI 记录 → 提示用户该 PR 未跑 CI,合并风险自担 + +### Step 4: 🤖AI 汇总评审 → 评论(AI 判断点 + 写命令) + +- 🤖AI 判断点:综合 Step2 审查 + Step3 CI,生成结构化 Review 评论,结构: + ``` + ## 🤖 自动审查结论 + **CI 状态**: ✅全绿 / ❌失败 / ⚠️未跑 + **可合并**: 是 / 否(原因) + + 🔴 阻断问题: + - <文件:行> <问题描述> + 🟡 建议改进: + - <建议> + 🟢 亮点: + - <肯定> + ``` +- ⚠️写入命令(评论全文先复述给用户确认): + - `gitlink-cli pr +comment --owner <owner> --repo <repo> --number <pr_number> --body "<评审全文>"` + +### Step 5: 🤖AI 质量达标判断 → 合并(AI 判断点 + 写命令) + +- 🤖AI 判断点:综合判断是否达标—— + - **达标条件**:无 🔴 阻断问题 + CI 全绿 + 无合并冲突 + - **不达标**:有任一阻断项 → 列出需修改项,**不合并**,等作者修 +- ⚠️写入命令(达标且用户明确确认后): + - `gitlink-cli pr +merge --owner <owner> --repo <repo> --number <pr_number>` +- 🤖AI 判断点:合并后可选——关闭相关 Issue、更新里程碑(需用户二次确认)。 + +--- + +## Agent 触发示例 + +**用户**:"帮我审查 jiangtx/gitlink-cli 的 PR #25,没问题就合并。" + +**Agent**: +1. Step0(🤖AI):确认 PR #25 → `pr +view` 拿基本信息。 +2. Step1(纯命令):`pr +diff`、`pr +files`、`pr +commits` 拉取变更(改了 5 个文件,+120/-30)。 +3. Step2(🤖AI):调 code-review 发现 1 个 🟡 建议(函数过长)+ commit-quality 发现提交信息符合规范;`pr +check-merge` 无冲突。 +4. Step3(纯命令):调 ci-health + `ci +builds`,CI 全绿。 +5. Step4(🤖AI):生成结构化评论 → 复述给用户 → 确认 → `pr +comment` 发布。 +6. Step5(🤖AI):无 🔴 阻断 + CI 全绿 → 判定达标 → 用户确认合并 → `pr +merge`。 +7. 输出:审查结论已评论、PR 已合并。 + +--- + +## 注意事项 + +- **只读 vs 写**:Step0-3 全只读;Step4 comment、Step5 merge 为写入,须确认。 +- **AI 审查 ≠ 替代人工**:本 Skill 的 AI 审查是辅助,最终合并决策须用户拍板。 +- **不替代子 Skill**:代码审查深度仍由 code-review/commit-quality/ci-health 各自负责,本 Skill 只编排顺序与做汇总判断。 +- **纯命令串联**:pr +view / +diff / +files / +commits / +comment / +merge / +check-merge + ci +builds = 8+ 命令,满足"≥3 个命令串联"。 diff --git a/skills/gitlink-project-bootstrap/SKILL.md b/skills/gitlink-project-bootstrap/SKILL.md new file mode 100644 index 0000000..8f3f7be --- /dev/null +++ b/skills/gitlink-project-bootstrap/SKILL.md @@ -0,0 +1,152 @@ +--- +name: gitlink-project-bootstrap +version: 1.0.0 +description: "项目一键初始化编排:从一句话项目描述出发,串联创建仓库 → 生成 README/LICENSE/CI 配置 → 初始化 label/milestone/member → 创建首批 Issue → 配置 webhook,实现端到端自动化。" +metadata: + requires: + bins: ["gitlink-cli"] + orchestrates: + - gitlink-repo + - gitlink-label + - gitlink-milestone + - gitlink-member + - gitlink-webhook + - gitlink-issue + - gitlink-onboarding + cliHelp: "gitlink-cli --help" +--- + +# gitlink-project-bootstrap(项目一键初始化编排) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),认证/权限/API 注意事项。** +**CRITICAL — 本 Skill 几乎全是写入操作(建仓库/建文件/加成员),每一步执行前务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 gitlink-cli。禁止用 gh(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — 失败后不自动 `repo +delete` 回滚,必须用户授权才清理。** + +--- + +## 工作流总览 + +```mermaid +flowchart TD + A[用户一句话需求] --> S1[Step1 🤖AI需求解析<br/>提取项目名/语言/描述/成员] + S1 --> CONF1{用户确认初始化清单?} + CONF1 -->|否| S1 + CONF1 -->|是| S2[Step2 创建仓库骨架<br/>repo+create + create-file×3] + S2 --> S3[Step3 初始化协作体系<br/>label+create / milestone+create / member+batch-add] + S3 --> S4[Step4 🤖AI推荐首批Issue<br/>issue+create ×N] + S4 --> S5[Step5 配置自动化<br/>webhook+create + webhook+test] + S5 --> S6[Step6 🤖AI输出初始化报告<br/>调 onboarding 生成新人文档] + S6 --> DONE[完成] +``` + +--- + +## 编排的子 Skill + +| 子 Skill | 职责 | 调用时机 | +|---|---|---| +| gitlink-repo | 创建仓库、写入 README/LICENSE/CI 文件 | Step2 | +| gitlink-label | 初始化标签体系(bug/feature/doc...) | Step3 | +| gitlink-milestone | 创建首个里程碑(Sprint 1 / MVP) | Step3 | +| gitlink-member | 批量添加初始成员 | Step3 | +| gitlink-issue | 创建首批 Issue | Step4 | +| gitlink-webhook | 配置 CI/webhook 回调 | Step5 | +| gitlink-onboarding | 生成新人引导文档 | Step6 | + +--- + +## 详细步骤 + +### Step 1: 🤖AI 需求解析(AI 判断点) + +- 🤖AI 判断点:从用户一句话提取并生成「初始化清单」: + - 项目名(name) + - 描述(description) + - 语言/技术栈(决定 README/CI 模板) + - 可见性(private 默认) + - 成员列表(user_id 或 login) + - 缺省字段需明确标注「(推断)」,不得静默补全。 +- 纯命令(核验身份/补全成员 ID): + - `gitlink-cli user +me` — 当前操作者 + - `gitlink-cli search +users -k <姓名>` — 反查用户 ID +- ⚠️强制确认:把完整清单出示给用户,确认或修改后才进入 Step2。 + +### Step 2: 创建仓库骨架(纯命令 + 🤖AI 生成内容) + +- 纯命令(建仓库): + - `gitlink-cli repo +create --name <name> --description "<desc>" --private <true|false>` +- 🤖AI 判断点:根据语言生成 README.md / LICENSE / CI 配置内容(如 Python→pytest 模板、Go→go build 模板)。 +- ⚠️写入命令(逐个创建文件,每个都需确认): + - `gitlink-cli repo +create-file --owner <owner> --repo <name> --filepath README.md --content "<内容>" --message "docs: add README"` + - `gitlink-cli repo +create-file --owner <owner> --repo <name> --filepath LICENSE --content "<MIT...>" --message "docs: add LICENSE"` + - `gitlink-cli repo +create-file --owner <owner> --repo <name> --filepath .gitlink-ci.yml --content "<CI配置>" --message "ci: add pipeline config"` + +### Step 3: 初始化协作体系(纯命令) + +- 标签体系(🤖AI 推荐配色): + - `gitlink-cli label +create --owner <owner> --repo <name> --name bug --color "#d73a4a"` + - `gitlink-cli label +create --owner <owner> --repo <name> --name feature --color "#a2eeef"` + - `gitlink-cli label +create --owner <owner> --repo <name> --name documentation --color "#0075ca"` +- 里程碑: + - `gitlink-cli milestone +create --owner <owner> --repo <name> --name "Sprint 1"` +- 成员(批量): + - `gitlink-cli member +batch-add --owner <owner> --repo <name> --user-ids <id1,id2,id3> --dry-run` — 先预览 + - 确认后去掉 `--dry-run` 正式添加 + +### Step 4: 🤖AI 推荐首批 Issue(AI 判断点 + 写入命令) + +- 🤖AI 判断点:根据项目类型推荐 3-5 个初始 Issue(如"搭建项目结构""配置 CI""编写使用文档""冒烟测试")。 +- ⚠️写入命令(确认后逐条创建): + - `gitlink-cli issue +create --owner <owner> --repo <name> --title "搭建项目结构" --body "<描述>" --label <label>` + +### Step 5: 配置自动化(纯命令) + +- ⚠️写入命令: + - `gitlink-cli webhook +create --owner <owner> --repo <name> --url <回调URL> --events push,merge_request` + - `gitlink-cli webhook +test --owner <owner> --repo <name> --id <webhook_id>` — 测试连通性 + +### Step 6: 🤖AI 输出初始化报告(AI 判断点) + +- 调子 Skill:`gitlink-onboarding` — 生成新人上手文档段落。 +- 🤖AI 判断点:汇总输出初始化报告: + - 仓库地址、可见性 + - 已创建文件清单(README/LICENSE/CI) + - 标签数、里程碑、成员数 + - 首批 Issue 列表 + - Webhook 配置与测试结果 +- 可选建议:`gitlink-cli repo +info --owner <owner> --repo <name>` 复核仓库状态。 + +--- + +## 错误处理与回滚 + +- **逐步熔断**:关键命令(repo +create、repo +create-file、milestone +create)失败,立即停止后续步骤。 +- **不自动删除**:本 Skill **不**自动 `repo +delete`,失败后由用户决定是否回滚。 +- **部分失败汇总**:成员添加、Issue 创建、Webhook 测试的个别失败不阻塞,统一在 Step6 报告列出。 + +--- + +## Agent 触发示例 + +**用户**:"帮我初始化一个 Python 的数据分析项目 data-analysis-demo,成员有小王和小李,先私有。" + +**Agent**: +1. Step1(🤖AI):解析 → 项目名 `data-analysis-demo`,语言 Python,成员含小王小李,私有 → 出示清单等待确认。 +2. 用户确认后: + - `repo +create --name data-analysis-demo --description "..." --private true` + - 🤖 生成 README.md / LICENSE(MIT) / .gitlink-ci.yml(python pytest) → 三个 `repo +create-file`。 +3. `label +create`(bug/feature/docs 三色);`milestone +create --name "MVP"`;`member +batch-add --user-ids <小王,小李> --dry-run` → 确认 → 正式添加。 +4. 🤖 推荐 5 条 Issue(环境脚手架/数据加载器/CI 联调/文档/冒烟测试)→ 确认 → 逐条 `issue +create`。 +5. 询问 Webhook URL → `webhook +create` + `webhook +test`。 +6. 调 onboarding 生成新人文档,输出初始化报告。 + +--- + +## 反模式(禁止行为) + +- ❌ 未确认初始化清单就执行写入命令。 +- ❌ 编造本文件未列出的命令(如 `repo +bootstrap`、`issue +bulk-create`)。 +- ❌ 用 `gh`、`curl` 绕过 gitlink-cli。 +- ❌ 静默补全缺失字段而不告知用户。 +- ❌ 失败后自动 `repo +delete` 清理(必须用户授权)。 diff --git a/skills/gitlink-release-notes/SKILL.md b/skills/gitlink-release-notes/SKILL.md new file mode 100644 index 0000000..68b348d --- /dev/null +++ b/skills/gitlink-release-notes/SKILL.md @@ -0,0 +1,152 @@ +--- +name: gitlink-release-notes +version: 1.0.0 +description: "Release Notes 生成:根据 commit 和 PR 记录生成结构化版本说明。当用户需要生成版本发布说明、制作 Release Notes、总结版本变更时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli release --help" +--- + +# gitlink-release-notes(Release Notes 生成) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何仓库。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +根据仓库的 PR 记录,自动生成结构化的 Release Notes: + +1. **获取版本信息** — 查看已有 Release 确定版本号 +2. **收集 PR 变更** — 获取合并的 PR 记录 +3. **自动分类** — 将变更归类为 Feature/BugFix/Refactor/Docs +4. **生成发布说明** — 按标准模板输出 Release Notes + +--- + +## 工作流:Release Notes 生成 + +### Step 1:获取仓库信息 + +```bash +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +``` + +提取:`full_name`, `version_releases_count`。 + +### Step 2:获取已有 Release + +```bash +gitlink-cli release +list --owner <owner> --repo <repo> --format json +``` + +查看发布历史,确定下一版本号。 + +### Step 3:获取已合并的 PR + +```bash +gitlink-cli pr +list --owner <owner> --repo <repo> --format json +``` + +筛选 `pull_request_status=1` (已合并) 的 PR。 + +### Step 4:分类汇总 + +| 类型 | 标题关键词 | 说明 | +|------|-----------|------| +| 🚀 Features | `feat:` | 新功能 | +| 🐛 Bug Fixes | `fix:` | Bug 修复 | +| 🔧 Refactoring | `refactor:`, `perf:`, `chore:` | 重构和性能优化 | +| 📝 Documentation | `docs:` | 文档更新 | +| ⚡ CI/CD | `ci:`, `build:` | CI/CD 流水线 | +| 🧪 Tests | `test:` | 测试相关 | + +### Step 5:生成 Release Notes + +--- + +## 输出模板 + +```markdown +# 🚀 Release {{version}} — {{release_title}} + +> 发布日期:{{release_date}} +> 仓库:[{{full_name}}](https://www.gitlink.org.cn/{{owner}}/{{repo}}) +> 此版本包含 {{total_prs}} 个合并 PR,由 {{total_contributors}} 位贡献者完成 + +--- + +## 🔢 变更统计 + +| 类型 | 数量 | +|------|------| +| 🚀 Features | {{feat_count}} | +| 🐛 Bug Fixes | {{fix_count}} | +| 🔧 Refactoring | {{refactor_count}} | +| 📝 Documentation | {{docs_count}} | +| ⚡ CI/CD | {{ci_count}} | +| 🧪 Tests | {{test_count}} | + +--- + +## 🚀 Features + +- **{{title}}** (#{{number}}) — {{author_login}} + +> 如无,输出:此版本无新增功能。 + +## 🐛 Bug Fixes + +- **{{title}}** (#{{number}}) — {{author_login}} + +> 如无,输出:此版本无 Bug 修复。 + +## 🔧 Refactoring + +- **{{title}}** (#{{number}}) — {{author_login}} + +## 📝 Documentation + +- **{{title}}** (#{{number}}) — {{author_login}} + +--- + +## 👏 贡献者 + +- @{{login}}({{pr_count}} 个 PR) + +--- + +## 📦 完整 PR 列表 + +| PR | 标题 | 类型 | 作者 | +|----|------|------|------| +| #{{number}} | {{title}} | {{type}} | {{author_login}} | + +> 此 Release Notes 由 `gitlink-cli` 自动生成 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| 无已合并 PR | 报告"新版本尚无合并的 PR" | +| PR 标题无类型前缀 | 归入"Other"分类 | +| PR 数量过多(>50) | 仅列出最近 30 个 PR | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **本 Skill 为纯只读操作**,不会修改仓库 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **PR 标题需遵循 Conventional Commits 规范**,否则分类可能不准确 +- ⚠️ **GitLink Release 功能使用率低**,`release +list` 可能返回空 diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index 858a827..d4ef80b 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -90,7 +90,7 @@ gitlink-cli auth login | 层级 | 格式 | 示例 | 适用场景 | |------|------|------|----------| | Shortcuts | `gitlink-cli <domain> +<verb>` | `gitlink-cli repo +info` | 高频操作,推荐优先使用 | -| Raw API | `gitlink-cli api <METHOD> <PATH>` | `gitlink-cli user +me` | Shortcuts 未覆盖的接口 | +| Raw API | `gitlink-cli api <METHOD> <PATH>` | `gitlink-cli api GET "/users/me"` | Shortcuts 未覆盖的接口 | ## GitLink API 注意事项 diff --git a/skills/gitlink-shared/references/api-reference.md b/skills/gitlink-shared/references/api-reference.md index 3c258b7..d5507a5 100644 --- a/skills/gitlink-shared/references/api-reference.md +++ b/skills/gitlink-shared/references/api-reference.md @@ -159,14 +159,15 @@ GitLink 的通知功能通过「消息」API 实现。 ### 调用示例 ```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 notification +list --format json # 标记单条已读 -gitlink-cli api POST "users/lindiwen23/messages/740214/read" --format json +gitlink-cli notification +read --id 740214 -# ⚠️ 路径不要以 / 开头,否则会被解析为本地文件路径 +# 全部标记已读 +gitlink-cli notification +read-all + +# 关注仓库通知 +gitlink-cli notification +watch --owner <owner> --repo <repo> ```