基础设施修复 #97
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
10
Makefile
10
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 ./...
|
||||
|
|
|
|||
|
|
@ -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 <name>",
|
||||
Short: "展开别名查看原命令",
|
||||
Long: "查看一个别名对应的原始命令。如果别名不存在则报错。",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: ` gitlink-cli alias +expand rl
|
||||
输出: rl → repo +list`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
aliases, _ := loadAliases()
|
||||
expanded, ok := aliases[args[0]]
|
||||
if !ok {
|
||||
return fmt.Errorf("别名 %s 不存在", args[0])
|
||||
}
|
||||
fmt.Printf("%s → %s\n", args[0], expanded)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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 <name> <command>": false, "+delete <name>": false, "+expand <name>": false}
|
||||
for _, sub := range subcmds {
|
||||
if _, ok := expectedUses[sub.Use]; ok {
|
||||
expectedUses[sub.Use] = true
|
||||
}
|
||||
}
|
||||
for use, found := range expectedUses {
|
||||
if !found {
|
||||
t.Errorf("subcommand %q not found", use)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <kind>[/id>` to a URL
|
||||
// builder. The "default" entry is used as a fallback that appends the raw arg
|
||||
// to the repo URL, preserving the original passthrough behaviour.
|
||||
var browsableKinds = []struct {
|
||||
kind string
|
||||
desc string
|
||||
}{
|
||||
{"issues", "Issue 列表 / 详情 (issues/42)"},
|
||||
{"pulls", "PR 列表 / 详情 (pulls/128)"},
|
||||
{"wiki", "Wiki 首页 / 页面 (wiki 或 wiki/API指南)"},
|
||||
{"actions", "CI/Actions 页面"},
|
||||
{"commits", "提交列表 / 详情 (commits/abc123)"},
|
||||
{"branches", "分支列表"},
|
||||
{"releases", "Release 列表 / 详情 (releases/v2.0)"},
|
||||
{"milestones", "里程碑页面"},
|
||||
{"labels", "标签管理页"},
|
||||
{"settings/hooks", "Webhook 设置页"},
|
||||
{"settings/collaboration", "成员管理页"},
|
||||
{"projects", "项目看板页"},
|
||||
}
|
||||
|
||||
// NewBrowseCmd creates the browse command for opening GitLink pages in a browser.
|
||||
func NewBrowseCmd() *cobra.Command {
|
||||
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 <arg>` to a web URL. With no arg → repo home.
|
||||
func resolveBrowseURL(b *web.Builder, owner, repo string, args []string) *web.ResourceURL {
|
||||
if len(args) == 0 || args[0] == "" {
|
||||
return b.RepoURL(owner, repo)
|
||||
}
|
||||
arg := strings.TrimPrefix(args[0], "/")
|
||||
// Split into kind and (optional) rest after the first "/".
|
||||
kind, rest, _ := strings.Cut(arg, "/")
|
||||
rest = strings.Trim(rest, "/")
|
||||
|
||||
switch {
|
||||
case kind == "issues" || kind == "issue":
|
||||
return b.IssueURL(owner, repo, atoiOrZero(rest))
|
||||
case kind == "pulls" || kind == "pr" || kind == "pull":
|
||||
return b.PRURL(owner, repo, atoiOrZero(rest))
|
||||
case kind == "wiki":
|
||||
return b.WikiURL(owner, repo, rest)
|
||||
case kind == "actions" || kind == "ci":
|
||||
return b.CIURL(owner, repo)
|
||||
case kind == "commits":
|
||||
return b.CommitURL(owner, repo, rest)
|
||||
case kind == "branches":
|
||||
return b.BranchURL(owner, repo, rest)
|
||||
case kind == "releases":
|
||||
return b.ReleaseURL(owner, repo, rest)
|
||||
case kind == "milestones":
|
||||
return b.MilestoneURL(owner, repo)
|
||||
case kind == "labels":
|
||||
return b.LabelURL(owner, repo)
|
||||
case arg == "settings/hooks":
|
||||
return b.WebhookURL(owner, repo)
|
||||
case arg == "settings/collaboration":
|
||||
return b.MemberURL(owner, repo)
|
||||
case kind == "settings":
|
||||
return b.RepoURL(owner, repo) // settings landing falls back to repo home
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <args>` with captured stdout.
|
||||
func runBrowse(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
old := stdout
|
||||
oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format
|
||||
buf := &bytes.Buffer{}
|
||||
stdout = buf
|
||||
defer func() {
|
||||
stdout = old
|
||||
cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt
|
||||
}()
|
||||
cmd := NewBrowseCmd()
|
||||
cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "")
|
||||
cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "")
|
||||
cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "")
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("browse %v: %v", args, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// runBrowseFmt runs `browse <resource>` with a specific --format value.
|
||||
func runBrowseFmt(t *testing.T, format, resource string) string {
|
||||
t.Helper()
|
||||
return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource)
|
||||
}
|
||||
|
||||
func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string {
|
||||
t.Helper()
|
||||
args := []string{"--owner", "o", "--repo", "r"}
|
||||
if resource != "" {
|
||||
args = append(args, resource)
|
||||
}
|
||||
if noOpen {
|
||||
args = append(args, "--no-open")
|
||||
}
|
||||
return runBrowse(t, args...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
// Package demo implements `gitlink-cli demo +run <showcase>`: 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
|
||||
}
|
||||
|
|
@ -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 <args>` 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
11
cmd/root.go
11
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
// Package show implements `gitlink-cli show <resource>`: 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 "🔗 <URL>"
|
||||
// - --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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <args>` 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
id,login,contributions
|
||||
<nil>,<nil>,<nil>
|
||||
|
|
|
@ -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":"<!doctype html>...<script>...</script></html>"}
|
||||
# → 几千字符的 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 模式,所有命令返回预置数据,演示不受影响。
|
||||
|
|
@ -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` | 为社区运营提供个人层面洞察 |
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
// 存储路径: <repo_root>/.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 配置版本化管理 |
|
||||
| 自动上下文提示 | 分支切换时自动提示上次工作状态 |
|
||||
| 会话历史 | 跨天/跨周工作无需重新回忆上下文 |
|
||||
|
|
@ -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 <showcase>
|
||||
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 | 自动执行 + 延迟 | 无人值守的全自动演示 |
|
||||
|
|
@ -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 <resource> [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 的对应关系
|
||||
|
||||
这使得课程演示从"纯终端操作展示"升级为"终端↔网页联动展示"。
|
||||
|
|
@ -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` 默认 `🔗 <URL>`(投影友好),`--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
|
||||
```
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 = "<YOUR_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("/<slug>")
|
||||
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)
|
||||
|
|
@ -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 = "<YOUR_PASSWORD>"
|
||||
API_KEY = "<YOUR_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()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
flask>=3.0
|
||||
requests>=2.31
|
||||
gunicorn>=21.2
|
||||
|
|
@ -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 <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
- `"open_devops": true` → CI 已激活
|
||||
- `"open_devops": false` → CI 未激活
|
||||
|
||||
**方法 2**:直接调用 `ci +builds`,CI 未激活时返回:
|
||||
|
||||
```json
|
||||
{"status": -1, "message": "接口数据异常"}
|
||||
```
|
||||
|
||||
> ⚠️ `ci +authorize` 命令在当前 CLI 版本(v0.1.18)中**不存在**。可用 CI 命令仅:`+builds`、`+logs`、`+restart`、`+stop`。
|
||||
|
||||
若 CI 未激活,报告中说明"CI 未启用",建议通过 GitLink Web 界面(仓库设置 → DevOps)开启,随后不再继续后续步骤。
|
||||
|
||||
### Step 2:获取构建历史
|
||||
|
||||
```bash
|
||||
gitlink-cli ci +builds --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
提取每次构建的:
|
||||
- `status` — 构建状态(success/failed/running/pending)
|
||||
- `created_at` / `finished_at` — 时间信息
|
||||
- `duration` — 耗时(如有)
|
||||
- `branch` — 触发分支
|
||||
|
||||
如构建数量 >30,取最近 30 次分析。
|
||||
|
||||
### Step 3:构建日志(失败构建)
|
||||
|
||||
对状态为 failed 的构建获取日志:
|
||||
|
||||
```bash
|
||||
gitlink-cli ci +logs --owner <owner> --repo <repo> --build <build_id> --format json
|
||||
```
|
||||
|
||||
> ⚠️ **控制调用量**:仅对最近 5 次失败构建获取日志,避免过多 API 调用。日志可能过大,提取关键错误行(最后 20 行)。
|
||||
|
||||
### Step 4:统计分析
|
||||
|
||||
#### 4.1 成功率计算
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
|------|----------|
|
||||
| 整体成功率 | 成功构建数 / 总构建数 × 100% |
|
||||
| 近 10 次成功率 | 最近 10 次中成功占比 |
|
||||
| 平均修复时间 | 从失败到下次成功的平均间隔 |
|
||||
|
||||
#### 4.2 健康度评分(满分 20)
|
||||
|
||||
| 维度 | 权重 | 评分标准 |
|
||||
|------|------|----------|
|
||||
| CI 激活 | 4 | 已激活=4,未激活=0 |
|
||||
| 构建成功率 | 5 | ≥90%=5,≥80%=4,≥70%=3,≥50%=2,<50%=1 |
|
||||
| 近期稳定性 | 5 | 近10次全部成功=5,8-9次=4,6-7次=3,4-5次=2,<4次=1 |
|
||||
| 构建频率 | 3 | 每天有构建=3,2-3天=2,每周=1,更少=0 |
|
||||
| 修复速度 | 3 | 失败后1次内修复=3,2-3次=2,>3次=1 |
|
||||
|
||||
### Step 5:生成 CI 健康报告
|
||||
|
||||
---
|
||||
|
||||
## 输出模板
|
||||
|
||||
```markdown
|
||||
# 🔧 CI 健康巡检报告:{{仓库名}}
|
||||
|
||||
> 巡检时间:{{当前时间}}
|
||||
> 仓库:{{full_name}}
|
||||
> CI 状态:{{ci_status_display}}
|
||||
|
||||
---
|
||||
|
||||
## 一、健康度总览
|
||||
|
||||
| 指标 | 数值 | 评分 |
|
||||
|------|------|------|
|
||||
| CI 激活状态 | {{activated_status}} | {{activate_score}}/4 |
|
||||
| 整体成功率 | {{success_rate}}%({{success_count}}/{{total_count}}) | {{success_score}}/5 |
|
||||
| 近期稳定性 | 近 10 次 {{recent_success}} 次成功 | {{stability_score}}/5 |
|
||||
| 构建频率 | {{build_frequency_desc}} | {{frequency_score}}/3 |
|
||||
| 修复速度 | {{repair_speed_desc}} | {{repair_score}}/3 |
|
||||
| **总分** | | **{{total_score}}/20** |
|
||||
|
||||
## 二、构建趋势
|
||||
|
||||
```
|
||||
最近 20 次构建:
|
||||
✅✅❌✅✅✅❌✅✅✅✅✅❌✅✅✅✅✅✅
|
||||
(✅=成功 ❌=失败)
|
||||
```
|
||||
|
||||
| 时间段 | 总构建 | 成功 | 失败 | 成功率 |
|
||||
|--------|--------|------|------|--------|
|
||||
| 最近 7 天 | {{w1_total}} | {{w1_success}} | {{w1_fail}} | {{w1_rate}}% |
|
||||
| 7-14 天 | {{w2_total}} | {{w2_success}} | {{w2_fail}} | {{w2_rate}}% |
|
||||
| 14-30 天 | {{w3_total}} | {{w3_success}} | {{w3_fail}} | {{w3_rate}}% |
|
||||
|
||||
## 三、故障分析
|
||||
|
||||
> 如无失败构建,输出:**🎉 分析期内无失败构建,CI 运行健康。**
|
||||
|
||||
| 构建 ID | 分支 | 失败时间 | 错误摘要 |
|
||||
|---------|------|----------|----------|
|
||||
| {{id}} | {{branch}} | {{time}} | {{error_summary}} |
|
||||
|
||||
### 故障模式分类
|
||||
|
||||
| 故障类型 | 次数 | 占比 |
|
||||
|----------|------|------|
|
||||
| 编译错误 | {{compile_count}} | {{compile_pct}}% |
|
||||
| 测试失败 | {{test_fail_count}} | {{test_fail_pct}}% |
|
||||
| 超时 | {{timeout_count}} | {{timeout_pct}}% |
|
||||
| 环境问题 | {{env_count}} | {{env_pct}}% |
|
||||
| 其他 | {{other_count}} | {{other_pct}}% |
|
||||
|
||||
## 四、改进建议
|
||||
|
||||
<!-- 根据分析结果,从以下列表中选择匹配的建议输出 -->
|
||||
|
||||
- **立即激活 CI**(当 CI 未激活时):前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务(CLI 暂不支持 `ci +activate`)
|
||||
- **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因
|
||||
- **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI
|
||||
- **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异常场景处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| CI 未激活 | 报告 CI 状态为"未激活",建议通过 Web 界面开启,不再继续后续步骤 |
|
||||
| 无构建记录 | 标注"仓库暂无 CI 构建记录" |
|
||||
| `ci +logs` 返回空 | 标注"日志不可用" |
|
||||
| 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ✅ **所有命令使用 `--format json`**,确保可解析
|
||||
- ✅ **CI 激活/关闭需通过 GitLink Web 界面**,CLI 不提供 `+activate`/`+deactivate` 命令
|
||||
- ✅ **Owner/repo 优先从 `git remote` 自动解析**
|
||||
- ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行
|
||||
- ⚠️ **构建历史无分页参数**,实际返回条数取决于 API
|
||||
- ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务
|
||||
- ⚠️ **`repo +info` 的 `open_devops` 字段**是判断 CI 是否激活的最可靠方式
|
||||
|
|
@ -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 <owner> --repo <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 <owner> --repo <repo> --format json
|
||||
|
||||
# 2b. 如果 Issue 数据也需要
|
||||
gitlink-cli issue +list --owner <owner> --repo <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 <username> --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) 包含原始命令输出数据
|
||||
|
|
@ -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 <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
提取 `issues_count` 了解 Issue 池总量,`default_branch` 确认主分支。
|
||||
|
||||
### Step 2:获取全部开放 Issue
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner <owner> --repo <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 <owner> --repo <repo> --state open --format json --page 2
|
||||
```
|
||||
|
||||
### Step 3:逐条深入分析
|
||||
|
||||
对过滤后的每条 Issue,获取详情:
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner <owner> --repo <repo> --number <project_issues_index> --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 条建议先按更新时间排序,优先分析最近活跃的
|
||||
|
|
@ -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 <owner> --repo <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 <owner> --repo <repo> --state open --format json
|
||||
|
||||
# 查看最近 Release 情况
|
||||
gitlink-cli release +list --owner <owner> --repo <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 以国内开发者和企业项目为主,搜索结果可能偏向中文技术生态,且镜像项目较多
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink Skills</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<nav class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-blue-600">🔧 GitLink Skills</a>
|
||||
<div class="text-sm text-gray-500">科技赋能 · 开源创新</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="text-center text-gray-400 text-sm py-8">
|
||||
GitLink Skills Web Service — 基于 Flask + DeepSeek API + gitlink-cli
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 mb-4">GitLink Skills 智能分析服务</h1>
|
||||
<p class="text-lg text-gray-500 max-w-2xl mx-auto">
|
||||
基于 DeepSeek API 和 gitlink-cli,通过 AI Agent 自动搜索、分析 GitLink 平台数据
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 工具区 -->
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">🔧 工具</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||
{% for key, skill in skills.items() %}
|
||||
{% if not key.startswith("lab-") %}
|
||||
<a href="/skills/{{ url_map.get(key, key) }}" class="block p-5 bg-white rounded-xl shadow-sm hover:shadow-md transition border hover:border-blue-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ skill.title }}</h3>
|
||||
<div class="mt-2 text-blue-600 text-sm font-medium">开始使用 →</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 科研实验室: 流程图布局 -->
|
||||
<h2 class="text-2xl font-bold text-green-700 mb-4">🔬 科研实验室</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">多技能串联合成的科研辅助工作流,覆盖完整科研项目生命周期</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-0 max-w-5xl mx-auto">
|
||||
{% 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 %}
|
||||
<div class="relative group w-36">
|
||||
<a href="/skills/{{ key }}" class="block">
|
||||
<div class="bg-green-50 border-2 border-green-300 rounded-xl p-4 text-center hover:bg-green-100 hover:border-green-500 hover:shadow-lg transition-all duration-200">
|
||||
<div class="text-sm font-bold text-green-800">{{ title }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="absolute z-10 invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-opacity duration-200 bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 text-white text-xs rounded-lg shadow-lg w-48 pointer-events-none">
|
||||
{{ desc }}
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div class="flex items-center justify-center w-8 h-8">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<a href="/skills/" class="text-blue-600 hover:underline text-sm mb-4 inline-block">← 返回首页</a>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">{{ info.title }}</h1>
|
||||
|
||||
{% if skill_name.startswith("lab-") and info.intro %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4 mb-6 text-sm text-green-800">
|
||||
{{ info.intro }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 功能介绍 + 预期输出 -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<details class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<summary class="px-4 py-3 cursor-pointer font-medium text-gray-700 hover:bg-gray-50 transition flex items-center gap-2">
|
||||
<span class="text-blue-500">📖</span> 功能介绍
|
||||
</summary>
|
||||
<div class="px-4 py-3 text-sm text-gray-600 border-t border-gray-100">
|
||||
{% if info.intro %}{{ info.intro }}{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
<details class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<summary class="px-4 py-3 cursor-pointer font-medium text-gray-700 hover:bg-gray-50 transition flex items-center gap-2">
|
||||
<span class="text-blue-500">📋</span> 预期输出格式与内容
|
||||
</summary>
|
||||
<div class="px-4 py-3 text-sm text-gray-600 border-t border-gray-100">
|
||||
{% if info.output_desc %}{{ info.output_desc|replace('\n', '<br>')|safe }}{% else %}
|
||||
<p>生成中文分析报告,包含:总览、详细分析、排行榜、建议等。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 输入表单 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
|
||||
<label class="block font-medium text-gray-700 mb-2">{{ info.prompt_label }}</label>
|
||||
<input id="user-input" type="text"
|
||||
class="w-full border rounded-lg px-4 py-3 text-gray-700 focus:ring-2 focus:ring-blue-300 focus:border-blue-400 outline-none"
|
||||
placeholder="{{ info.prompt_placeholder }}">
|
||||
<p id="hint-text" class="text-sm text-gray-400 mt-2"></p>
|
||||
<button onclick="runSkill()" id="run-btn"
|
||||
class="mt-4 bg-blue-600 hover:bg-blue-700 text-white font-medium px-6 py-3 rounded-lg transition disabled:opacity-50">
|
||||
🚀 开始分析
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 进度 -->
|
||||
<div id="progress" class="hidden mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span class="text-gray-600 font-medium">AI 正在分析中...</span>
|
||||
</div>
|
||||
<div id="log" class="text-sm text-gray-500 space-y-1 font-mono max-h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报告输出 -->
|
||||
<div id="report" class="hidden">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 overflow-auto">
|
||||
<div id="report-content" class="markdown-body"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button onclick="downloadTxt()" class="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition font-medium">
|
||||
📥 下载 TXT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div id="error" class="hidden bg-red-50 border border-red-200 rounded-xl p-6 text-red-700"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const skillName = "{{ skill_name }}";
|
||||
const hints = {
|
||||
"research-tracker": "例如:微服务框架、AI Agent、DevOps 流水线、大模型、数据可视化",
|
||||
"contributor-insight": "例如:ci4s/ci4sManagement-cloud, jiangtx/gitlink-cli",
|
||||
"issue-triage": "例如:Gitlink/gitlink-cli, ci4s/ci4sManagement-cloud",
|
||||
"ci-health": "例如:jiangtx/gitlink-cli, Gitlink/gitlink-cli",
|
||||
"lab-hotspot": "例如:大模型、AI Agent、微服务、DevOps、数据可视化",
|
||||
"lab-insight": "例如:ci4s/ci4sManagement-cloud, Gitlink/gitlink-cli",
|
||||
"lab-compliance": "例如:ci4s/ci4sManagement-cloud, Gitlink/gitlink-cli",
|
||||
"lab-match": "例如:ci4s/ci4sManagement-cloud, Gitlink/gitlink-cli",
|
||||
"lab-track": "例如:ci4s/ci4sManagement-cloud, Gitlink/gitlink-cli",
|
||||
};
|
||||
document.getElementById('hint-text').textContent = '💡 ' + (hints[skillName] || '');
|
||||
|
||||
function runSkill() {
|
||||
const input = document.getElementById('user-input').value.trim();
|
||||
if (!input) { alert('请输入参数'); return; }
|
||||
|
||||
document.getElementById('progress').classList.remove('hidden');
|
||||
document.getElementById('report').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
document.getElementById('run-btn').disabled = true;
|
||||
document.getElementById('log').innerHTML = '<div>⏳ 提交中...</div>';
|
||||
|
||||
fetch('/api/run', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `skill=${encodeURIComponent(skillName)}&input=${encodeURIComponent(input)}`
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
document.getElementById('error').classList.remove('hidden');
|
||||
document.getElementById('error').textContent = '❌ ' + data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('log').innerHTML = '<div class="text-green-600">✅ 分析完成</div>';
|
||||
|
||||
// Render Markdown
|
||||
const reportDiv = document.getElementById('report-content');
|
||||
reportDiv.innerHTML = marked.parse(data.report || '');
|
||||
document.getElementById('report').classList.remove('hidden');
|
||||
|
||||
// Render Mermaid diagrams (find code.language-mermaid)
|
||||
document.querySelectorAll('code.language-mermaid').forEach((code) => {
|
||||
const pre = code.parentElement;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mermaid';
|
||||
div.textContent = code.textContent;
|
||||
pre.parentElement.replaceChild(div, pre);
|
||||
});
|
||||
if (typeof mermaid !== 'undefined') {
|
||||
mermaid.run({ nodes: document.querySelectorAll('.mermaid') });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('error').classList.remove('hidden');
|
||||
document.getElementById('error').textContent = '❌ 请求失败: ' + err.message;
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById('progress').classList.add('hidden');
|
||||
document.getElementById('run-btn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadTxt() {
|
||||
const text = document.getElementById('report-content').textContent;
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'report-' + new Date().toISOString().slice(0,10) + '.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
||||
<script>mermaid.initialize({ startOnLoad: false, theme: 'default' });</script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown.min.css">
|
||||
<style>
|
||||
#report-content.markdown-body { padding: 0; }
|
||||
#report-content.markdown-body table { display: table; width: 100%; }
|
||||
#report-content.markdown-body pre { background: #f6f8fa; border-radius: 6px; position: relative; }
|
||||
#report-content.markdown-body code { font-size: 13px; }
|
||||
#report-content.markdown-body img { max-width: 100%; }
|
||||
/* Mermaid SVG should be visible */
|
||||
#report-content.markdown-body .mermaid svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:<name>=<value>"
|
||||
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{},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(`<!DOCTYPE html><html><body>Login</body></html>`))
|
||||
}))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":2,"branches":[{"name":"master","protected":true},{"name":"dev","protected":false}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"members":[{"id":101325,"login":"member-a","role":"Developer"},{"id":126177,"login":"member-b","role":"Developer"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"commits":[{"sha":"abc1234","message":"demo commit","author":{"login":"jiangtx","name":"演示"}}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"contributors":[{"login":"jiangtx","contributions":120,"email":"demo@example.com"}]}
|
||||
|
|
@ -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}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"labels":[{"id":1,"name":"bug","color":"#fc2929"},{"id":2,"name":"enhancement","color":"#84b6eb"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"Go":85.5,"Shell":10.0,"Other":4.5}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"versions":[{"id":1,"name":"Sprint 6","description":"演示里程碑","effective_date":"2026-07-15","status":"open"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":2,"notifications":[{"id":1,"subject":"通知 1","status":1},{"id":2,"subject":"通知 2","status":1}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"pipelines":[{"id":1,"name":"build","file":"build.yml","status":"success","run_number":42}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"projects":[{"id":1,"name":"演示看板","identifier":"demo-board"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"pulls":[{"id":1,"pull_request_id":1,"title":"示例 PR","status":0,"base":"master","head":"dev"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"releases":[{"id":1,"tag_name":"v2.0","name":"v2.0","body":"演示发布"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"total_count":1,"webhooks":[{"id":51348,"url":"https://ci.example.com/hook","http_method":"POST","events":["push","create"]}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"id":99,"project_issues_index":99,"subject":"演示 Issue (demo)","description":"通过 demo 模式创建"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"id":3,"name":"P0","color":"#FF0000","description":"演示标签"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"id":51348,"message":"success (demo)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"login":"jiangtx","total contributions":847,"contributions":[{"date":"2026-07-01","contributions":5},{"date":"2026-07-02","contributions":8}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"login":"jiangtx","user_id":148911,"username":"jiangtx","name":"演示用户","image_url":"https://gitlink.org.cn/demo.png"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":0,"pages":[{"title":"首页","wiki":{"title":"首页"}},{"title":"API 使用指南","wiki":{"title":"API 使用指南"}}]}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ]
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <remote> <head_branch>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue