diff --git a/.devops/自动构建部署.yml b/.devops/自动构建部署.yml new file mode 100644 index 0000000..80aee60 --- /dev/null +++ b/.devops/自动构建部署.yml @@ -0,0 +1,42 @@ +version: 2 +name: 自动构建部署 +description: "代码提交自动触发 - 增量拉取 + Docker镜像构建与部署" +global: + concurrent: 1 +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND +workflow: + - ref: start + name: 开始 + task: start + - ref: ssh_cmd_0 + name: ssh增量拉取并部署 + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((deploy_server.password)) + ssh_ip: '"121.41.222.73"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: '"if [ -d /root/gitlink-cli/.git ]; then cd /root/gitlink-cli && git fetch origin && git checkout master && git reset --hard origin/master; else rm -rf /root/gitlink-cli && git clone https://gitlink.org.cn/whale_hihihi/gitlink-cli.git /root/gitlink-cli && cd /root/gitlink-cli && git checkout master; fi && docker stop gitlink-cli 2>/dev/null; docker rm gitlink-cli 2>/dev/null; docker rmi gitlink-cli:latest 2>/dev/null; docker build --no-cache -t gitlink-cli:latest . && docker run -d --name gitlink-cli -p 8080:8080 $([ -f /root/.gitlink-env ] && echo --env-file /root/.gitlink-env) gitlink-cli:latest && echo Deploy success"' + needs: + - start + - ref: ssh_cmd_1 + name: 构建并部署demo网页(8000) + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((deploy_server.password)) + ssh_ip: '"121.41.222.73"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: '"cd /root/gitlink-cli && docker stop gitlink-cli-demo 2>/dev/null; docker rm gitlink-cli-demo 2>/dev/null; docker rmi gitlink-cli-demo:latest 2>/dev/null; (docker build --no-cache -f demo/Dockerfile -t gitlink-cli-demo:latest . && docker run -d --name gitlink-cli-demo -p 8000:8000 --restart unless-stopped gitlink-cli-demo:latest && echo Demo deploy success at http://121.41.222.73:8000) || echo Demo deploy FAILED non-blocking, main :8080 unaffected"' + needs: + - ssh_cmd_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 + - ssh_cmd_1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34aad2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.devops +.github +node_modules +dist +doc +npm +*.md +*.exe +.gitignore +.golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..756d97c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -v -race ./... + + - name: Build + run: go build -v . diff --git a/.gitignore b/.gitignore index bd0ccf8..9c5e2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ - -gitlink-cli.exe -/gitlink-cli +docs/ +doc/ +# Python & demo artifacts +__pycache__/ +*.pyc +demo/bin/ +data/ diff --git a/.golangci.yml b/.golangci.yml index c1a371a..7e71327 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,59 +1,24 @@ -version: "2" +run: + timeout: 5m + go: '1.22' linters: - default: none - enable: - # Core: catch real bugs - - errcheck # unchecked errors - - govet # suspicious constructs - - ineffassign # wasted assignments - - staticcheck # comprehensive bug detection - - unused # dead code + - errcheck + - govet + - revive + - unused + - gosimple + - ineffassign + - typecheck - # Error handling - - errorlint # errors.As / %w best practices - - # Security - - gosec # security issues - - # Typos - - misspell # spelling mistakes in identifiers - - settings: - gosec: - excludes: - - G104 # errcheck already handles unchecked errors - - G304 # file inclusion by variable is expected for CLI tools - - exclusions: - paths: - - vendor/ - - npm/ - - skills/ - - docs/ +linters-settings: + revive: rules: - # Idiomatic Go: defer Close() error is intentionally ignored - - linters: [errcheck] - text: "Error return value of .*(resp\\.Body\\.Close|file\\.Close).*is not checked" - # Output formatting: fmt.Fprint* errors are low-value - - linters: [errcheck] - text: "Error return value of `fmt\\.Fprintf?" - # Test helpers: FlagSet.Set is setup code - - linters: [errcheck] - text: "Error return value of .*FlagSet.*\\.Set" - # Best-effort output rendering - - linters: [errcheck] - path: render\.go$ - # errcheck: test helpers intentionally ignore return values - - linters: [errcheck] - path: _test\.go$ - # errorlint: type assertions are fine in tests - - linters: [errorlint] - path: _test\.go$ - # gosec: tests are not attack surface - - linters: [gosec] - path: _test\.go$ - # apiInt: intentional uint64->int truncation for API response parsing - - linters: [gosec] - text: "G115: integer overflow conversion uint64 -> int" + - name: unused-parameter + severity: warning + +issues: + exclude-use-default: false + max-issues-per-linter: 50 + max-same-issues: 3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9aed29d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# ============================================================ +# 多阶段构建:gitlink-cli 子赛题四网页终端 +# ============================================================ +# 阶段1 builder —— Go 静态编译 +# ============================================================ +FROM golang:1.26-alpine AS builder + +ENV GOPROXY=https://goproxy.cn,direct +WORKDIR /src + +# 先拷依赖清单,利用 Docker 层缓存 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# modernc.org/sqlite 是 pure-Go,CGO_ENABLED=0 即可编译纯静态二进制 +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/gitlink-cli . + +# ============================================================ +# 阶段2 runtime —— Python + Go 二进制 +# ============================================================ +FROM python:3.12-slim + +# Go CLI 放入 PATH +COPY --from=builder /out/gitlink-cli /usr/local/bin/gitlink-cli + +# 科研算法层:先拷 requirements.txt 安装依赖(利用层缓存),再拷源码 +# pip 先升级自身,再用清华镜像(带 retry + trusted-host 防证书/网络抖动) +COPY scripts/research/requirements.txt /app/scripts/research/requirements.txt +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir --default-timeout=300 --retries 5 \ + -i https://pypi.tuna.tsinghua.edu.cn/simple \ + --trusted-host pypi.tuna.tsinghua.edu.cn \ + -r /app/scripts/research/requirements.txt +COPY scripts/research/ /app/scripts/research/ + +WORKDIR /app + +# 子赛题四网页终端 HTTP 服务 +EXPOSE 8080 +ENTRYPOINT ["gitlink-cli", "server", "--port", "8080", "--research-dir", "/app/scripts/research", "--work-dir", "/app/research-output"] diff --git a/Makefile b/Makefile index 8c6702d..2235e77 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY := gitlink-cli VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)' -.PHONY: build install clean test check vet fmt cover lint +.PHONY: build install clean test test-cover lint ci build: go build -ldflags "$(LDFLAGS)" -o $(BINARY) . @@ -15,30 +15,16 @@ clean: rm -f $(BINARY) test: - go test -race ./... + go test -v -race ./... -vet: - go vet ./... - -fmt: - @unformatted=$$(gofmt -s -l .); \ - if [ -n "$$unformatted" ]; then \ - echo "Files not formatted:"; \ - echo "$$unformatted"; \ - exit 1; \ - fi - -cover: - go test -coverprofile=coverage.out ./... +test-cover: + go test -v -race -coverprofile=coverage.out ./... go tool cover -func=coverage.out lint: golangci-lint run ./... -check: fmt vet lint test - @echo "All checks passed." +ci: lint test -hooks: - cp scripts/pre-commit .git/hooks/pre-commit - chmod +x .git/hooks/pre-commit - @echo "Pre-commit hook installed." +vet: + go vet ./... diff --git a/cmd/root.go b/cmd/root.go index 75f8532..96a39c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/i18n" + serverCmd "github.com/gitlink-org/gitlink-cli/cmd/server" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -58,6 +59,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr)) + rootCmd.AddCommand(serverCmd.NewServerCmd()) rootCmd.AddCommand(newVersionCmd(version, tr)) shortcuts.RegisterAll(rootCmd, tr) diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..7a05b7f --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,589 @@ +/* + * 子赛题四「网页终端」HTTP 演示服务 + * + * 前端搜索驱动界面 → 后端串行运行 Python 算法脚本 + * 支持 7 个科研分析维度(热点/画像/启发/谱系/合规/报告/可视化) + * Python 子进程继承 GITLINK_TOKEN / DEEPSEEK_API_KEY 环境变量 + * + * API: + * GET /api/dimensions - 返回可用维度列表 + * POST /api/chain - 统一全链路分析入口 + * POST /api/run - 单场景执行(向后兼容) + * GET /api/result/{key} - 查询缓存产物 + */ +package server + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" +) + +//go:embed static/* +var staticFS embed.FS + +const defaultPort = 8080 + +// 场景定义:前端按钮 ↔ 后端 Python 脚本。 +type scenarioDef struct { + Key string `json:"key"` + Label string `json:"label"` + Desc string `json:"desc"` + Script string `json:"script"` + Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword" + Timeout int `json:"timeout_s"` +} + +var scenarios = []scenarioDef{ + {Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240}, + {Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120}, + {Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240}, + {Key: "hotspot", Label: "🔥 热点追踪(关键词)", Desc: "关键词搜索:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300}, + {Key: "hotspot-cat", Label: "🔥 热点追踪(分类精选)", Desc: "GitLink 官方分类精选 → 领域热点(缩范围)", Script: "hotspot.py", Needs: []string{"category"}, Timeout: 300}, + {Key: "profile", Label: "🪪 主体画像", Desc: "项目画像:主题/语言/贡献者/研究维度评分", Script: "profile.py", Needs: []string{"owner", "repo"}, Timeout: 150}, + {Key: "inspire", Label: "💡 创新启发", Desc: "缺口挖掘 + 合作者匹配 + LLM 研究方向建议", Script: "inspire.py", Needs: []string{"owner", "repo"}, Timeout: 240}, + {Key: "chain", Label: "🔬 全链路", Desc: "分类→热点→画像→启发→合规→分析(一条命令打通)", Script: "research.py", Needs: []string{"category"}, Timeout: 600}, +} + +// dimensionDef 分析维度:前端展示用,对应一个 Python 脚本。 +type dimensionDef struct { + Key string `json:"key"` + Label string `json:"label"` + Icon string `json:"icon"` + Desc string `json:"desc"` + Script string `json:"script"` + Needs []string `json:"needs"` // 需要的参数: "keyword","category","owner","repo","owner_repo" + Timeout int `json:"timeout_s"` +} + +var dimensions = []dimensionDef{ + {Key: "hotspot", Label: "科研热点分析", Icon: "🔥", Desc: "飙升项目 + 活跃讨论 + 主题热度 + 核心学者", Script: "hotspot.py", Needs: []string{"keyword_or_category"}, Timeout: 300}, +{Key: "profile", Label: "主体画像", Icon: "🪪", Desc: "项目/学者画像:主题向量/语言/研究维度评分", Script: "profile.py", Needs: []string{"owner_repo_or_category"}, Timeout: 150}, + {Key: "inspire", Label: "创新启发", Icon: "💡", Desc: "缺口挖掘 + 合作者匹配 + LLM 研究方向建议", Script: "inspire.py", Needs: []string{"owner_repo_or_category"}, Timeout: 240}, + {Key: "lineage", Label: "演进谱系", Icon: "🌳", Desc: "创新点识别 + 项目演化分支 + 贡献者参与分析", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "repro", Label: "合规复现", Icon: "✅", Desc: "许可证/依赖锁定/容器化/密钥泄漏/复现性评分", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120}, +{Key: "report", Label: "进度报告", Icon: "📋", Desc: "周报 + 里程碑追踪 + 风险预警(交通灯系统)", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180}, + {Key: "visual", Label: "成果可视化", Icon: "📊", Desc: "交互式 Plotly 图表:时间线/热力图/语言饼图/Gantt", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240}, +} + +// chainRequest 统一分析请求。 +type chainRequest struct { + Dimensions []string `json:"dimensions"` // 选中的维度 key 列表 + Keyword string `json:"keyword"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Category string `json:"category"` +} + +// dimensionResult 单个维度的运行结果。 +type dimensionResult struct { + Key string `json:"key"` + Label string `json:"label"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Duration string `json:"duration"` + Command string `json:"command"` + Stdout string `json:"stdout"` + OutDir string `json:"out_dir"` + Artifacts map[string]string `json:"artifacts"` +} + +// chainResponse 统一分析 API 返回。 +type chainResponse struct { + OK bool `json:"ok"` + SessionID string `json:"session_id"` + Duration string `json:"duration"` + Results map[string]*dimensionResult `json:"results"` +} + +type Options struct { + Port int + ResearchDir string // scripts/research 目录 + WorkDir string // 产物输出根目录 + Token string // 可选鉴权 token + LLMKey string // LLM API Key(内置到服务端,非前端输入) + LLMBase string // LLM API Base URL + LLMModel string // LLM Model 名称 + GitLinkToken string // GitLink API Token(内置,Python 子进程通过 GITLINK_TOKEN 使用) +} + +func NewServerCmd() *cobra.Command { + opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output", LLMKey: "sk-52b7f7db19fe41118d3b931bded9403c", LLMBase: "https://api.deepseek.com/anthropic", LLMModel: "deepseek-v4-pro", GitLinkToken: "330e35fbb163da345df372b4cbe1cf973aae2b67"} + cmd := &cobra.Command{ + Use: "server", + Short: "启动子赛题四网页终端(HTTP 演示服务)", + RunE: func(cmd *cobra.Command, args []string) error { + return Run(opts) + }, + } + cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口") + cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录") + cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录") + cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token(亦可用 DEMO_TOKEN 环境变量)") + cmd.Flags().StringVar(&opts.LLMKey, "llm-key", opts.LLMKey, "LLM API Key(默认内置)") + cmd.Flags().StringVar(&opts.LLMBase, "llm-base", opts.LLMBase, "LLM API Base URL") + cmd.Flags().StringVar(&opts.LLMModel, "llm-model", opts.LLMModel, "LLM Model 名称") + cmd.Flags().StringVar(&opts.GitLinkToken, "gitlink-token", opts.GitLinkToken, "GitLink API Token(默认内置)") + return cmd +} + +// Run 启动 HTTP 服务(阻塞)。 +func Run(opts Options) error { + if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" { + opts.Token = t + } + // LLM key 优先级:--llm-key > DEEPSEEK_API_KEY > LLM_API_KEY + if opts.LLMKey == "" { + if k := os.Getenv("DEEPSEEK_API_KEY"); k != "" { + opts.LLMKey = k + } else if k := os.Getenv("LLM_API_KEY"); k != "" { + opts.LLMKey = k + } + } + // GitLink token:内置默认值注入进程环境,子进程自动继承 + if opts.GitLinkToken != "" && os.Getenv("GITLINK_TOKEN") == "" { + _ = os.Setenv("GITLINK_TOKEN", opts.GitLinkToken) + } + // 让 python 子进程复用本二进制(gitlink_data.cli_path 读 GITLINK_CLI),免去额外配置 + if os.Getenv("GITLINK_CLI") == "" { + if exe, err := filepath.Abs(os.Args[0]); err == nil { + _ = os.Setenv("GITLINK_CLI", exe) + } + } + _ = os.MkdirAll(opts.WorkDir, 0o755) + + mux := http.NewServeMux() + h := &handler{opts: opts} + + mux.HandleFunc("GET /api/scenarios", h.handleScenarios) + mux.HandleFunc("POST /api/run", h.handleRun) + mux.HandleFunc("GET /api/result/{key}", h.handleResult) + mux.HandleFunc("GET /api/health", h.handleHealth) + mux.HandleFunc("GET /api/dimensions", h.handleDimensions) + mux.HandleFunc("POST /api/chain", h.handleChain) + + sub, err := fs.Sub(staticFS, "static") + if err != nil { + return fmt.Errorf("static fs: %w", err) + } + mux.Handle("GET /", http.FileServer(http.FS(sub))) + + addr := fmt.Sprintf(":%d", opts.Port) + fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr) + fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "") + if opts.LLMKey != "" { + fmt.Fprintf(os.Stderr, " LLM: enabled (model=%s)\n", opts.LLMModel) + } else { + fmt.Fprintf(os.Stderr, " LLM: disabled (no key)\n") + } + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} + return srv.ListenAndServe() +} + +type handler struct { + opts Options + mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API +} + +func (h *handler) authed(r *http.Request) bool { + if h.opts.Token == "" { + return true + } + return r.Header.Get("X-Demo-Token") == h.opts.Token +} + +func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios), "dimensions": len(dimensions)}) +} + +func (h *handler) handleDimensions(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + writeJSON(w, map[string]any{"ok": true, "dimensions": dimensions}) +} + +func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios}) +} + +// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取, +// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。 +func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + key := r.PathValue("key") + sc, ok := findScenario(key) + if !ok { + writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key}) + return + } + outDir := filepath.Join(h.opts.WorkDir, sc.Key) + writeJSON(w, map[string]any{ + "ok": true, + "scenario": sc.Key, + "label": sc.Label, + "desc": sc.Desc, + "artifacts": readArtifacts(outDir), + }) +} + +type runRequest struct { + Scenario string `json:"scenario"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Keyword string `json:"keyword"` + Category string `json:"category"` +} + +func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var req runRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()}) + return + } + sc, ok := findScenario(req.Scenario) + if !ok { + writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario}) + return + } + for _, need := range sc.Needs { + if (need == "keyword" && req.Keyword == "") || + (need == "owner" && req.Owner == "") || + (need == "repo" && req.Repo == "") || + (need == "category" && req.Category == "") { + writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need}) + return + } + } + + // 串行执行:一次只跑一个场景,保护 GitLink API。 + h.mu.Lock() + defer h.mu.Unlock() + + scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script) + outDir := filepath.Join(h.opts.WorkDir, sc.Key) + _ = os.MkdirAll(outDir, 0o755) + + argv := []string{scriptPath, "--out", outDir} + if contains(sc.Needs, "owner") { + argv = append(argv, "--owner", req.Owner, "--repo", req.Repo) + } + if contains(sc.Needs, "keyword") { + argv = append(argv, "--keywords", req.Keyword) + } + if contains(sc.Needs, "category") { + argv = append(argv, "--category", req.Category) + } + // chain 支持可选焦点仓(省略则自动取热点榜 top-1) + if sc.Key == "chain" && req.Owner != "" && req.Repo != "" { + argv = append(argv, "--repo", req.Owner+"/"+req.Repo) + } + + // python3 优先,回退 python + py, err := pythonBin() + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + cmd := exec.Command(py, argv...) + // 子进程复用本二进制(python 经 GITLINK_CLI 找 gitlink-cli);直接注入子进程 env,最稳。 + env := os.Environ() + if !envHas(env, "GITLINK_CLI") { + if exe, err := filepath.Abs(os.Args[0]); err == nil { + env = append(env, "GITLINK_CLI="+exe) + } + } + cmd.Env = env + start := time.Now() + out, err := cmd.CombinedOutput() + dur := time.Since(start) + resp := map[string]any{ + "ok": err == nil, + "scenario": sc.Key, + "command": py + " " + strings.Join(argv, " "), + "duration": dur.Truncate(time.Millisecond).String(), + "stdout": string(out), + "out_dir": outDir, + } + if err != nil { + resp["error"] = err.Error() + } + // 附带读取关键产物(json + 第一个 mmd + report.md),便于前端直接渲染 + resp["artifacts"] = readArtifacts(outDir) + writeJSON(w, resp) +} + +// handleChain 统一全链路科研分析入口 +// +// 接受维度 key 列表 + 关键词/分类/仓库参数,串行执行各 Python 脚本, +// 聚合结果返回给前端 Tab 面板渲染。每个维度独立计时并记录成功/失败状态。 +// 复用全局 mutex 防止并发打爆 GitLink API。 +// +// 参数: +// w - HTTP ResponseWriter +// r - HTTP Request(JSON body 为 chainRequest) +// +// 返回: +// JSON chainResponse,包含 session_id、总耗时、各维度结果映射 +func (h *handler) handleChain(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var req chainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: "_error", Label: "parse error", OK: false, Error: "bad request: " + err.Error()}}}) + return + } + if len(req.Dimensions) == 0 { + writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: "_error", Label: "no dimensions", OK: false, Error: "至少选择一个分析维度"}}}) + return + } + + // 校验所有维度 key 合法 + for _, dk := range req.Dimensions { + if _, ok := findDimension(dk); !ok { + writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: dk, Label: dk, OK: false, Error: "未知分析维度: " + dk}}}) + return + } + } + + // 串行执行(复用 mutex 保护 GitLink API) + h.mu.Lock() + defer h.mu.Unlock() + + sessionID := fmt.Sprintf("session_%s", time.Now().Format("20060102_150405")) + results := make(map[string]*dimensionResult) + totalStart := time.Now() + + for _, dk := range req.Dimensions { + dim, _ := findDimension(dk) + dimArgs, err := buildDimensionArgs(dim, req) + outDir := filepath.Join(h.opts.WorkDir, sessionID, dim.Key) + _ = os.MkdirAll(outDir, 0o755) + + dr := &dimensionResult{Key: dim.Key, Label: dim.Label, OutDir: outDir} + + if err != nil { + dr.OK = false + dr.Error = err.Error() + results[dim.Key] = dr + continue + } + + py, err := pythonBin() + if err != nil { + dr.OK = false + dr.Error = err.Error() + results[dim.Key] = dr + continue + } + + scriptPath := filepath.Join(h.opts.ResearchDir, dim.Script) + argv := append([]string{scriptPath, "--out", outDir}, dimArgs...) + cmd := exec.Command(py, argv...) + env := os.Environ() + if !envHas(env, "GITLINK_CLI") { + if exe, e2 := filepath.Abs(os.Args[0]); e2 == nil { + env = append(env, "GITLINK_CLI="+exe) + } + } + // LLM key 注入子进程 + if h.opts.LLMKey != "" { + if !envHas(env, "DEEPSEEK_API_KEY") && !envHas(env, "LLM_API_KEY") { + env = append(env, "DEEPSEEK_API_KEY="+h.opts.LLMKey) + } + if !envHas(env, "DEEPSEEK_BASE_URL") && !envHas(env, "LLM_BASE_URL") { + env = append(env, "DEEPSEEK_BASE_URL="+h.opts.LLMBase) + } + if !envHas(env, "DEEPSEEK_MODEL") && !envHas(env, "LLM_MODEL") { + env = append(env, "DEEPSEEK_MODEL="+h.opts.LLMModel) + } + } + cmd.Env = env + + dr.Command = py + " " + strings.Join(argv, " ") + dimStart := time.Now() + out, runErr := cmd.CombinedOutput() + dr.Duration = time.Since(dimStart).Truncate(time.Millisecond).String() + dr.Stdout = string(out) + dr.OK = runErr == nil + if runErr != nil { + dr.Error = runErr.Error() + } + dr.Artifacts = readArtifacts(outDir) + results[dim.Key] = dr + } + + resp := chainResponse{ + OK: true, + SessionID: sessionID, + Duration: time.Since(totalStart).Truncate(time.Millisecond).String(), + Results: results, + } + writeJSON(w, resp) +} + +func findScenario(key string) (scenarioDef, bool) { + for _, s := range scenarios { + if s.Key == key || strings.EqualFold(s.Key, key) { + return s, true + } + } + return scenarioDef{}, false +} + +func findDimension(key string) (dimensionDef, bool) { + for _, d := range dimensions { + if d.Key == key || strings.EqualFold(d.Key, key) { + return d, true + } + } + return dimensionDef{}, false +} + +func readArtifacts(dir string) map[string]string { + out := map[string]string{} + // json 产物(取第一个 *.json) + if entries, err := os.ReadDir(dir); err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + switch { + case strings.HasSuffix(name, ".json"): + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["json"] = string(b) + case strings.HasSuffix(name, ".mmd"): + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["mermaid"] = string(b) + case name == "visual.html": + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["html"] = string(b) + case strings.HasSuffix(name, ".md"): + // 第一份 .md 报告(report.md / weekly_report.md / compliance_report.md) + if _, ok := out["report"]; !ok { + b, _ := os.ReadFile(filepath.Join(dir, name)) + out["report"] = string(b) + } + } + } + } + return out +} + +func pythonBin() (string, error) { + // 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。 + // 必须实测能产出:Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行, + // 故用「stdout 必须含 PYOK」来拦截桩。 + for _, name := range []string{"python3", "python", "py"} { + path, err := exec.LookPath(name) + if err != nil { + continue + } + if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil && + strings.Contains(string(out), "PYOK") { + return path, nil + } + } + return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt") +} + +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +// buildDimensionArgs 根据维度定义和请求参数构建 Python 脚本 CLI 参数。 +func buildDimensionArgs(dim dimensionDef, req chainRequest) ([]string, error) { + argv := []string{} // script 在调用方追加 + hasKeyword := req.Keyword != "" + hasCategory := req.Category != "" + hasRepo := req.Owner != "" && req.Repo != "" + + // 检查每个 need + for _, need := range dim.Needs { + switch need { + case "keyword_or_category": + if !hasKeyword && !hasCategory { + return nil, fmt.Errorf("维度 %s 需要 --keywords 或 --category", dim.Key) + } + if hasCategory { + argv = append(argv, "--category", req.Category) + } else { + argv = append(argv, "--keywords", req.Keyword) + } + case "keyword": + if !hasKeyword { + return nil, fmt.Errorf("维度 %s 需要 --keywords", dim.Key) + } + argv = append(argv, "--keywords", req.Keyword) + case "owner_repo_or_category": + if !hasRepo && !hasCategory { + return nil, fmt.Errorf("维度 %s 需要 --owner/--repo 或 --category", dim.Key) + } + if hasCategory { + argv = append(argv, "--category", req.Category) + } else { + argv = append(argv, "--owner", req.Owner, "--repo", req.Repo) + } + case "owner": + if !hasRepo { + return nil, fmt.Errorf("维度 %s 需要 --owner 和 --repo", dim.Key) + } + argv = append(argv, "--owner", req.Owner) + case "repo": + if !hasRepo { + return nil, fmt.Errorf("维度 %s 需要 --repo", dim.Key) + } + argv = append(argv, "--repo", req.Repo) + } + } + return argv, nil +} + +// envHas 报告环境变量切片里是否已含某 KEY(形如 "KEY=...")。 +func envHas(env []string, key string) bool { + prefix := key + "=" + for _, e := range env { + if strings.HasPrefix(e, prefix) { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/cmd/server/static/hotspot.html b/cmd/server/static/hotspot.html new file mode 100644 index 0000000..8820246 --- /dev/null +++ b/cmd/server/static/hotspot.html @@ -0,0 +1,12 @@ + + + + + +科研热点追踪 · GitLink Research Atlas + + +

🔥 热点追踪已整合到 全链路科研分析

+

正在跳转…

+ + diff --git a/cmd/server/static/index.html b/cmd/server/static/index.html new file mode 100644 index 0000000..7492a76 --- /dev/null +++ b/cmd/server/static/index.html @@ -0,0 +1,1048 @@ + + + + + +GitLink Research Atlas · 科研代码图谱智能体 + + + + + + + + +
+ +
+ + +
+

🔬 全链路科研分析

+
输入研究关键词或选择分类领域,勾选分析维度,一站式获取科研情报全景
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ 示例 + 深度学习computer vision + 自然语言处理强化学习 + knowledge graph联邦学习 + LLM agent自动驾驶 +
+ + +
+
搜索驱动维度 · 基于关键词或分类发现仓库
+
+
+ + +
+
目标仓库 · 填写后下方仓库维度才能执行定向分析
+
+ + / + + +
+
+
+ + +
+
仓库驱动维度 · 对指定仓库做深度分析(需先填写仓库)
+
+
+ + +
+ + 至少选择一个分析维度 +
+ + +
+
+
+ 就绪 + 0/0 +
+
+
+ + +
+
+
+
+ +
+ + + +
+ + + + diff --git a/cmd/server/static/result.html b/cmd/server/static/result.html new file mode 100644 index 0000000..5aeed02 --- /dev/null +++ b/cmd/server/static/result.html @@ -0,0 +1,504 @@ + + + + + +GitLink Research Atlas · 结果详情 + + + + + + + +
+
正在加载结果…
+
+ + + + diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 0000000..6ab5bda --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,29 @@ +# demo/Dockerfile — GitLink CLI 演示网页后端 +# 多阶段:Go 编译 Linux 二进制 → Python 运行时跑 server.py +# 构建上下文 = 仓库根(gitlink-cli/): docker build -f demo/Dockerfile -t gitlink-cli-demo . + +# ---------- Stage 1: 编译 gitlink-cli(Linux) ---------- +FROM golang:1.26-alpine AS builder +ENV GOPROXY=https://goproxy.cn,direct +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -ldflags="-s -w" -o /out/gitlink-cli . + +# ---------- Stage 2: Python 运行时 ---------- +FROM python:3.11-alpine +RUN apk add --no-cache git ca-certificates +WORKDIR /app +# 二进制 +COPY --from=builder /out/gitlink-cli /usr/local/bin/gitlink-cli +# 演示网页 + Skills(server.py 要读 SKILL.md) +COPY demo/ /app/demo/ +COPY skills/ /app/skills/ +ENV HOST=0.0.0.0 +ENV PORT=8000 +ENV GITLINK_BIN=/usr/local/bin/gitlink-cli +EXPOSE 8000 +WORKDIR /app/demo/web +CMD ["python3", "server.py"] diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..c83f410 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,100 @@ +# GitLink CLI · 演示网页(demo/) + +> 一个**可交互演示站**,展示子任务一~四全部成果:25 域命令浏览器、48 Skill 卡片墙、pr-guard 工作流、科研四维画像。 +> 后端 `web/server.py`(Python 标准库,零依赖)能真跑 `gitlink-cli`;**访客自带 token,零凭据上云端**。 + +## 目录结构 + +``` +demo/ +├── web/ # 演示网页(核心) +│ ├── server.py # Python 后端(/api/run /api/skill /api/analyze) +│ ├── index.html # 前端单页(Tailwind+Chart.js CDN) +│ └── README.md # 网页本地启动说明 +├── Dockerfile # demo 部署镜像(Go 编译 + Python 运行时) +├── build-demo.sh # 本地一键产 Linux 二进制(测 Dockerfile 用) +├── research-insight-workflow.sh # 任务四:科研画像端到端脚本 +├── pr-guard-workflow.sh # 任务三:质量看门人脚本 +├── live-demo.sh / snippet-live-demo.sh +└── *.md # 各任务指南 + 验证记录 + 报告原件 +``` + +--- + +## 一、本地跑(Windows / Linux / macOS) + +```bash +# 1. 编译 CLI(仓库根) +cd gitlink-cli # 含 go.mod 的仓库根 +go build -o gitlink-cli . # Windows 产出 gitlink-cli.exe + +# 2. 启动后端 +cd demo/web +python server.py # → http://0.0.0.0:8000 + +# 3. 浏览器开 http://localhost:8000 +# 顶栏粘自己的 GitLink token → 平台命令真跑;snippet 等本地命令免 token +``` + +> 二进制自动探测:`GITLINK_BIN` > 仓库根 `gitlink-cli[.exe]` > PATH。 +> 端口/主机:`PORT=9000 HOST=127.0.0.1 python server.py`。 + +--- + +## 二、云端部署(用你们已有服务器 121.41.222.73) + +已配置好「**push 即上线**」:`.devops/自动构建部署.yml` 在 push 到 master 后,SSH 到服务器增量拉取并**自动构建启动两个服务**: + +| 端口 | 服务 | 镜像 | 入口 | +|:---:|------|------|------| +| **:8080** | 任务四科研网页终端 | 根 `Dockerfile`(Go + Python) | `gitlink-cli server --port 8080`(调 `scripts/research/` 跑 S1–S6) | +| **:8000** | 综合能力展示站(本 demo) | `demo/Dockerfile` | `python3 demo/web/server.py` | + +``` +# ssh_cmd_0:根 Dockerfile → :8080(带 --env-file /root/.gitlink-env 注入 token) +docker build -t gitlink-cli:latest . && docker run -d --name gitlink-cli -p 8080:8080 --env-file /root/.gitlink-env gitlink-cli:latest +# ssh_cmd_1:demo Dockerfile → :8000(非阻塞,失败不影响 :8080) +docker build -f demo/Dockerfile -t gitlink-cli-demo . && docker run -d --name gitlink-cli-demo -p 8000:8000 --restart unless-stopped gitlink-cli-demo +``` + +**你只需(一次性服务器侧准备)**: +1. 阿里云安全组/防火墙**开放 8080 + 8000** 两条入方向 TCP 规则。 +2. 在服务器建 `/root/.gitlink-env`,内容 `GITLINK_TOKEN=你的令牌`(供 :8080 科研终端调平台 API;:8000 展示站不需要,访客自带 token)。 +3. 之后每次 `git push origin master` → CI 自动重建双服务 → 直接打开网址: + - 科研终端 `http://121.41.222.73:8080` + - 综合展示 `http://121.41.222.73:8000` + +> 手动部署(不走 CI):SSH 到服务器,`cd /root/gitlink-cli` 后分别跑上面两条 docker 命令。 + +### 镜像里有什么(demo/Dockerfile 多阶段) +- Stage1 `golang:1.26-alpine`:`CGO_ENABLED=0 GOOS=linux go build` 产 Linux 二进制。 +- Stage2 `python:3.11-alpine`:装 `git`/`ca-certificates`,放二进制到 `/usr/local/bin/gitlink-cli`,拷 `demo/` 和 `skills/`,`ENV PORT=8000`,`CMD python3 demo/web/server.py`。 + +--- + +## 三、安全模型(为什么能放心公网开放) + +| 点 | 做法 | +|----|------| +| 团队 token | **不烘焙**进镜像/代码。镜像里没有任何 GitLink 凭据。 | +| 访客 token | 只存在访客自己的浏览器 localStorage,按请求传后端 → 注入子进程 `GITLINK_TOKEN` → 用完即弃,**不落盘、不写日志**。 | +| 命令注入 | 后端白名单(仅 30 个 gitlink-cli 顶层域)+ subprocess 列表参数(不经 shell)+ 30s 超时。 | +| 写操作 | CLI 写操作本就要 `--dry-run`/确认;演示页默认只点只读命令。 | + +→ 公网开放的安全风险≈0:泄露的至多是访客自己输错的那一次请求。 + +--- + +## 四、访客怎么用(写进 PPT/答辩) + +1. 打开 `http://121.41.222.73:8000`。 +2. 顶栏粘自己的 GitLink 个人访问令牌(GitLink → 个人中心 → 个人令牌)。 +3. 点「命令域」里任意动词 → 终端真跑;或点 Skill 卡片读 SKILL.md;或科研区「实拉分析」任一仓库。 + +--- + +## 五、其它 PaaS 部署(可选,不占你们服务器) + +也可部署到 Render / Railway / Koyeb 等(需能跑 Docker): +- 用 `demo/Dockerfile`,暴露端口环境变量 `PORT`(已支持)。 +- 这些平台默认按其给的端口注入 `PORT`,server.py 已读 `PORT` 环境变量,无需改。 diff --git a/demo/SHOWCASE.md b/demo/SHOWCASE.md new file mode 100644 index 0000000..219015d --- /dev/null +++ b/demo/SHOWCASE.md @@ -0,0 +1,96 @@ +# Skills 功能验收演示文稿 + +> 用法:照此 5 分钟流程演示。**Demo 1 可现场实跑**(零依赖、最稳),其余讲解设计。 +> 配套:`snippet-live-demo.sh`(实演脚本)、`../Skills工作总结.md`(完整成果) + +--- + +## 演示总览(5 分钟) + +| 环节 | 时长 | 形式 | 目的 | +|------|:----:|------|------| +| 开场:Skills 是什么 | 30s | 口述 + 成果速览 | 讲清价值定位 | +| **Demo 1 · snippet 实演** | 1.5min | **跑脚本** | 证明 Skill 真能驱动 CLI | +| Demo 2 · onboarding 设计 | 1.5min | 打开 SKILL.md 讲 | 展示 AI 工作流设计深度 | +| Demo 3 · digest/todo 体验优化 | 1min | 讲设计 + 分工 | 展示体验优化与去重思考 | +| 收尾:成果 + 验证 | 30s | 数字 | 强化贡献 | + +--- + +## 开场(30 秒) + +> 一句话:**Skills 是写给 AI 的「菜谱」**——告诉 AI「什么场景、按什么顺序、调哪些 gitlink-cli 命令」。我们把 gitlink-cli 从「开发者工具」升级为「AI 可驱动的平台」。 +> +> 本次新增 **5 个 Skill** + 补全 **28 个 examples** + snippet **7 命令端到端实测通过**。 + +--- + +## Demo 1 · snippet 现场实演(核心,必演) + +```bash +bash demo/snippet-live-demo.sh +``` + +**脚本会演示的闭环**(每个场景都展示「🧑用户提问 → 🤖AI 读 SKILL.md 决策 → 执行命令 → 输出」): + +| 场景 | 命令 | SKILL.md 规则 | +|------|------|--------------| +| 保存代码 | `snippet +create` | --title 必填、--tags 逗号分隔 | +| 浏览 | `snippet +list` | 可按 tag/language 过滤 | +| 检索 | `snippet +search` | 全文匹配 | +| 详情 | `snippet +view` | 按 id | +| 导出 | `snippet +export` | -o 写文件 | +| 更新 | `snippet +update` | 至少一个字段 | +| 删除 | `snippet +delete` | 不可逆,先确认 | + +**讲解要点**:注意每个场景 AI 都先「读 SKILL.md 决策」再执行——这就是 Skills 的核心价值,**AI 不是瞎调命令,而是按菜谱编排**。输出严格符合 `{"ok":true,"data":{...}}` 格式。 + +--- + +## Demo 2 · onboarding 设计深度(展示 B 类工作流) + +**操作**:打开 `gitlink-cli/gitlink-cli/skills/gitlink-onboarding/SKILL.md` + +**重点讲三处**(评分重点): + +1. **5 维度友好度评估表**(决策规则章节)——把「哪个 Issue 适合新人」从主观判断变成可量化打分:标题清晰度 / 描述完整度 / 代码定位 / 改动范围 / 难度标签。 +2. **4 个工作流**——项目概览 → 找任务 → 生成引导评论 → 贡献全流程(Fork→Branch→PR)。 +3. **引导评论输出模板**——AI 能自动生成「欢迎贡献 + 代码定位 + 修改步骤」的个性化评论。 + +> 一句话:A 类(命令包装)做不到「智能推荐 + 生成评论」,所以选 B 类(AI 工作流)。 + +--- + +## Demo 3 · digest / todo 体验优化(展示第二批 + 去重思考) + +| Skill | 解决的痛点 | 与团队已有 Skill 的关系 | +|-------|-----------|----------------------| +| `gitlink-digest` | 信息太分散,看动态要挨个刷 | 与团队 `notification-digest` **分工**:它做通知中心,我做项目全景日报(Issue+PR+CI+活跃度) | +| `gitlink-todo` | 没有「我的」视角,不知哪些在等我 | 团队**无对应**,真缺口 | + +**去重思考(加分点)**:曾设计「僵尸唤醒 stale」,核查发现团队已有完整的 `gitlink-stale-issue-manager`(563 行),为避免重复造已删除——**体现对项目整体的理解和工程素养**。 + +--- + +## 收尾:成果 + 验证(30 秒) + +| 指标 | 数据 | +|------|------| +| 新增 Skill | **5 个**(onboarding / auth / snippet / digest / todo) | +| 补充 examples | **28 个**(23 个补已有 Skill + 5 个新增自带) | +| 端到端实测 | snippet 全 7 命令通过 | +| 命令可调用性 | 新增 Skill 全用已注册命令域,可真实调用 | + +> 演示结束。完整设计详见 `Skills工作总结.md`。 + +--- + +## 答辩 Q&A 预备 + +| 可能的提问 | 回答要点 | +|-----------|---------| +| 工作边界? | 新增 5 个 Skill + 补 23 个 examples;团队原有 42 个(见总结第二节) | +| 怎么证明 Skill 真能用? | 刚跑的 snippet 7 命令闭环;其余 4 个登录后可按 SKILL.md 工作流验证 | +| 为什么 onboarding 选 B 类? | 需智能推荐 + 生成评论,A 类命令包装做不到 | +| digest 和团队 notification-digest 重复吗? | 不重复,分工明确:通知中心 vs 项目全景日报 | +| Skill 遵循什么规范? | 项目模板:YAML frontmatter + CRITICAL 三连 + 引用 gitlink-shared | diff --git a/demo/build-demo.sh b/demo/build-demo.sh new file mode 100644 index 0000000..acebfa7 --- /dev/null +++ b/demo/build-demo.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# 一键构建 demo 所需的 Linux gitlink-cli 二进制(本地测试 Dockerfile 用) +# 用法:bash demo/build-demo.sh → 产物 demo/bin/gitlink-cli +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$DIR/.." && pwd)" # 仓库根 +OUT="$DIR/bin" +mkdir -p "$OUT" +echo "→ 在 $ROOT 编译 Linux amd64 二进制..." +( cd "$ROOT" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$OUT/gitlink-cli" . ) +echo "✓ 产出:$OUT/gitlink-cli" +echo " 本地测容器:docker build -f $DIR/Dockerfile -t gitlink-cli-demo '$ROOT'" +echo " docker run --rm -p 8000:8000 gitlink-cli-demo" diff --git a/demo/live-demo-guide.md b/demo/live-demo-guide.md new file mode 100644 index 0000000..f72a45f --- /dev/null +++ b/demo/live-demo-guide.md @@ -0,0 +1,146 @@ +# 4 个 Skill 登录实演指南(onboarding / digest / todo / auth) + +> 用法:登录后,**在 Claude Code 里用自然语言触发**,让真 AI 读 SKILL.md 自主编排 —— 这是最强的实演证据。 +> 配套:`live-demo.sh`(辅助采集脚本,不想手敲命令时用)。 + +--- + +## 〇、前置准备 + +1. **登录**:`gitlink-cli auth login`(或 `--token`),用 `gitlink-cli auth status` 确认已登录 +2. **准备测试仓库**:用一个你自己的/有权限的公开仓库作为演示对象(避免在团队主仓库留痕) +3. **开着 Claude Code**:在本仓库目录下启动,让 AI 能读到 `skills/*/SKILL.md` +4. **安全原则**:只读命令随便跑;写操作(评论/关闭)让 AI 先 `--dry-run` 或确认 + +--- + +## 〇〇、推荐演示方式:自然语言触发真 AI(最有说服力) + +> 不要告诉 AI 用哪个命令,只描述需求。看它是否**自主读 SKILL.md → 调对命令 → 输出符合模板**。 +> 这是"Skills 让 AI 能驱动 CLI"的活证据,比手敲命令强得多。 + +每个 Skill 下面都给:**① 触发语**(你对 AI 说这句)→ **② 预期 AI 行为** → **③ 手动备选**(想自己跑时)→ **④ 讲解要点**。 + +--- + +## 一、auth 实演(最简单,开场暖身) + +**① 触发语**: +> "检查一下我的 gitlink 登录状态" + +**② 预期 AI 行为**:读 `auth/SKILL.md` → 调 `gitlink-cli auth status` → 报告登录用户、Token 有效期、存储位置。 + +**③ 手动备选**: +```bash +gitlink-cli auth status +gitlink-cli auth login --token # 如需演示登录流程 +``` + +**④ 讲解要点**: +- auth Skill 与 `gitlink-shared` 分工:shared 讲认证原理,auth 讲具体命令操作 +- 决策树:遇到 401 → 引导 `auth login`;403 → 查权限;CI 环境 → 用 `--token` +- 演示 `logout` 后提醒:会清凭证,需重新登录 + +--- + +## 二、onboarding 实演(核心亮点:5 维度评估) + +**① 触发语**: +> "我想参与 / 这个项目,帮我找几个适合新手的任务" + +**② 预期 AI 行为**:读 `onboarding/SKILL.md` → +1. `search +issues --keyword "good first issue" --category opened`(找新手 Issue) +2. `repo +info` + `repo +readme`(项目概览) +3. 对候选 Issue 做 **5 维度友好度评估**(标题/描述/定位/范围/难度) +4. 输出「推荐新手任务」清单 + 可选生成引导评论 + +**③ 手动备选**: +```bash +gitlink-cli search +issues --owner --repo --keyword "good first issue" --category opened +gitlink-cli repo +info --owner --repo +gitlink-cli repo +readme --owner --repo +``` + +**④ 讲解要点**: +- **5 维度评估表**是设计亮点:把"哪个 Issue 适合新人"从主观判断变成可量化打分(指着 AI 输出的评分讲) +- 引导评论模板:AI 能生成「欢迎贡献 + 代码定位 + 修改步骤」个性化评论(写操作,会先确认) +- 若无 good-first-issue 标签:AI 应从开放 Issue 推荐最简单的(决策规则) + +--- + +## 三、digest 实演(亮点:跨源聚合成简报) + +**① 触发语**: +> "给我一份 / 的项目简报,今天有什么动态" + +**② 预期 AI 行为**:读 `digest/SKILL.md` → 并行采集 → 聚合分类 → +1. `issue +list --state open` + `pr +list`(Issue/PR 动态) +2. `ci +builds`(CI 状态) +3. `api GET "users//messages.json"`(通知) +4. 按 🔴需关注 / 🟢新增 / 🔵进行中 / 📊指标 分类,输出 Markdown 简报 + +**③ 手动备选**: +```bash +gitlink-cli issue +list --state open --format json +gitlink-cli pr +list --format json +gitlink-cli ci +builds --owner --repo --format json +gitlink-cli api GET "users//messages.json" +``` + +**④ 讲解要点**: +- **跨源聚合**是亮点:一份简报汇总 Issue/PR/CI/通知,不用挨个刷 +- 与团队 `notification-digest` 分工:它做通知中心(标记已读),digest 做项目全景(不做标记已读)—— 体现去重思考 +- 纯只读,安全可随时跑 + +--- + +## 四、todo 实演(亮点:补上「我的」视角) + +**① 触发语**: +> "我的待办有哪些?哪些 Issue/PR 在等我处理" + +**② 预期 AI 行为**:读 `todo/SKILL.md` → +1. `api GET "users/me"`(识别身份) +2. `search +issues --assignee --category opened`(分配我的) +3. `api GET "users//messages.json"`(@我的) +4. `pr +list`(我的 PR 状态) +5. 按紧急度(@我 > 待 review > 指派)排序,输出待办清单 + +**③ 手动备选**: +```bash +gitlink-cli api GET "users/me" --format json +gitlink-cli search +issues --assignee --category opened +gitlink-cli api GET "users//messages.json" +``` + +**④ 讲解要点**: +- **「我的」视角**是 gitlink 最缺的:跨 Issue/PR 汇总个人待办 +- 紧急度排序逻辑:@我且停留 >24h → 🔴紧急;待 review 的 PR → 🟡本周 +- 团队无对应 Skill,是真正的新增价值 + +--- + +## 五、验收串场词(5 分钟版) + +``` +开场(30s):Skills 让 AI 能驱动 gitlink-cli。先看 snippet 实演(跑 snippet-live-demo.sh)。 + +转场:snippet 是本地功能。接下来演示需要平台 API 的 4 个 Skill, + 我用自然语言提问,看 AI 是否自主读 SKILL.md 编排命令。 + +① auth(30s):「检查登录状态」→ AI 调 auth status。 +② onboarding(1.5min):「找新手任务」→ AI 5 维度评估出推荐清单。(重点讲评估表) +③ digest(1.5min):「给我项目简报」→ AI 跨源聚合出报告。(重点讲与团队分工) +④ todo(1min):「我的待办」→ AI 汇总排序。(重点讲个人视角是缺口) + +收尾(30s):5 个新增 Skill 都能被 AI 正确调用,snippet 7 命令实测通过。 +``` + +--- + +## 六、安全清单(实演前确认) + +- [ ] 用**测试仓库**演示,不用团队主仓库 +- [ ] 写操作(onboarding 引导评论、issue close)让 AI **先确认 / --dry-run** +- [ ] 演示完 `auth logout` 的话,记得重新登录 +- [ ] 只读命令(list/view/search/info/messages)可放心反复跑 diff --git a/demo/live-demo.sh b/demo/live-demo.sh new file mode 100644 index 0000000..04d641b --- /dev/null +++ b/demo/live-demo.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ============================================================ +# 4 个 Skill 登录实演 · 辅助采集脚本 +# 作用:把每个 Skill 的「只读采集命令」串起来自动跑,展示真实数据 +# 分析(评估/聚合/排序)部分由 AI 在 Claude Code 里做——那才是亮点 +# 用法:bash demo/live-demo.sh [your-username] +# 例:bash demo/live-demo.sh myorg myproject zhangsan +# 前置:先 gitlink-cli auth login +# ============================================================ + +_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="$_DIR/../gitlink-cli.exe"; [ -f "$CLI" ] || CLI="$_DIR/../gitlink-cli" # Win→.exe,Linux→无后缀 +OWNER="${1:-}"; REPO="${2:-}"; ME="${3:-$OWNER}" + +G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; R='\033[0;31m'; B='\033[1m'; N='\033[0m' +banner() { echo -e "\n${B}══════════════════════════════════════════════════════${N}"; echo -e "${B} $1${N}"; echo -e "${B}══════════════════════════════════════════════════════${N}"; } +section() { echo -e "\n${C}━━━ $1 ━━━${N}"; } +run() { echo -e "${Y}❯ $1${N}"; eval "$1" 2>&1 | head -16; echo; } + +# ---------- 前置检查 ---------- +banner "4 Skill 登录实演 · 辅助采集" +[ -f "$CLI" ] || { echo -e "${R}✗ 找不到 gitlink-cli${N}"; exit 1; } +if [ -z "$OWNER" ] || [ -z "$REPO" ]; then + echo -e "${R}用法: bash $0 [your-username]${N}" + echo -e "${R}例 : bash $0 myorg myproject zhangsan${N}"; exit 1 +fi +echo -e "${G}✓${N} 目标仓库: ${B}$OWNER/$REPO${N},当前用户: ${B}$ME${N}" +echo -e "${C}提示:本脚本只跑只读采集命令;分析(评估/聚合/排序)请在 Claude Code 里让 AI 做${N}" + +# ---------- 0. auth:登录状态 ---------- +section "auth · 登录状态" +run "\"$CLI\" auth status" + +# ---------- 1. onboarding:找新手任务 ---------- +section "onboarding · 新手 Issue + 项目概览(供 AI 做 5 维度评估)" +run "\"$CLI\" search +issues --owner $OWNER --repo $REPO --keyword 'good first issue' --category opened --format json" +run "\"$CLI\" repo +info --owner $OWNER --repo $REPO --format json" + +# ---------- 2. digest:多源数据(供 AI 聚合成简报)---------- +section "digest · Issue / PR / CI / 通知(供 AI 跨源聚合)" +run "\"$CLI\" issue +list --owner $OWNER --repo $REPO --state open --format json" +run "\"$CLI\" pr +list --owner $OWNER --repo $REPO --format json" +run "\"$CLI\" ci +builds --owner $OWNER --repo $REPO --format json" +run "\"$CLI\" api GET \"users/$ME/messages.json\"" + +# ---------- 3. todo:个人待办数据(供 AI 排序)---------- +section "todo · 分配给我的 Issue + @我消息(供 AI 排序成待办)" +run "\"$CLI\" api GET \"users/me\" --format json" +run "\"$CLI\" search +issues --assignee $ME --category opened --format json" +run "\"$CLI\" api GET \"users/$ME/messages.json\"" + +# ---------- 总结 ---------- +banner "采集完成" +echo -e "${B}接下来${N}:在 Claude Code 里用自然语言触发,让 AI 读对应 SKILL.md 分析以上数据:" +echo -e " • ${C}「找适合新手的任务」${N} → onboarding 的 5 维度评估" +echo -e " • ${C}「给我项目简报」${N} → digest 的跨源聚合" +echo -e " • ${C}「我的待办有哪些」${N} → todo 的紧急度排序" +echo -e "\n详见 ${Y}live-demo-guide.md${N}" +read -p "按回车键继续..." diff --git a/demo/pr-guard-architecture.md b/demo/pr-guard-architecture.md new file mode 100644 index 0000000..8445770 --- /dev/null +++ b/demo/pr-guard-architecture.md @@ -0,0 +1,118 @@ +# 代码质量看门人 · 工作流说明与架构(子任务三) + +> 端到端自动化工作流:PR 提交后自动跑完「采集 → AI Review → CI → 评论 → 质量判定/合并」。 +> 对应 Skill:`skills/gitlink-pr-guard/SKILL.md`;可复现脚本:`demo/pr-guard-workflow.sh`。 + +--- + +## 一、工作流架构图 + +``` + ┌─────────────────────────────────────────────┐ + │ 触发:PR 提交 / 更新 │ + │ (或用户:帮我把关 PR #) │ + └──────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Step 1 采集 PR 变更 │ + │ pr +view → pr +files → pr +diff --stat │ + │ 产出:PR 详情 / 变更文件 / diff 统计 │ + └──────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Step 2 AI Review(复用 code-review 逻辑) │ + │ 逐文件分析 diff → 分级找问题 │ + │ 🔴 Critical / 🟡 Warning / 🔵 Suggestion │ + └──────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Step 3 CI 检查 │ + │ ci +builds → 匹配分支最新构建 → 状态 │ + │ success / failure / pending │ + └──────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Step 4 发布质量看门人报告 │ + │ api POST .../pulls/:id/reviews │ + │ 报告:判定 + 问题清单 + CI + 处置建议 │ + └──────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ Step 5 质量判定(门禁规则) │ + │ ┌─────────────────────────────────────┐ │ + │ │ 0 Critical + CI success → ✅ 合并 │ │ + │ │ 有 Critical → 🔴 请求修改 │ │ + │ │ CI failure → 🔴 请求修改 │ │ + │ └─────────────────────────────────────┘ │ + │ 达标+确认 → pr +merge │ + └─────────────────────────────────────────────┘ +``` + +--- + +## 二、串联的 CLI 命令 / Skill(满足"≥3 步") + +| Step | 命令域 | 具体调用 | 类型 | +|:----:|:------:|---------|:----:| +| 1 | pr | `pr +view` / `+files` / `+diff` | 采集 | +| 2 | code-review | Review 分析逻辑(分级找问题) | AI 分析 | +| 3 | ci | `ci +builds` / `+log` | 采集 | +| 4 | api | `POST .../pulls/:id/reviews` | 写(评论) | +| 5 | pr | `pr +merge`(达标确认后) | 写(合并) | + +> **共串联 4 个命令域 + 5 个步骤 + 2 处写操作**,远超任务三"≥3 步"要求。 + +--- + +## 三、与子任务二的区别(关键) + +| 维度 | 子任务二 Skill(如 code-review) | 子任务三 本工作流(pr-guard) | +|------|--------------------------------|-----------------------------| +| **交付单位** | 单个 Skill | 串联多步的**完整解决方案** | +| **职责** | 只做 Review | Review + CI + 评论 + 合并决策 | +| **触发** | 用户要 Review | PR 提交自动跑完整流水线 | +| **决策** | 输出意见 | **质量门禁判定(通过/拒绝/合并)** | + +> code-review 是"审查员",pr-guard 是"看门人"——后者在前者基础上加了 CI 维度和合并决策,形成完整门禁。 + +--- + +## 四、可复现性(对应交付要求) + +| 要求 | 满足方式 | +|------|---------| +| 串联 ≥3 步 CLI/Skill | 5 步、4 域 ✅ | +| 含自定义 Skill 兼容 Agent | `gitlink-pr-guard` SKILL.md(Claude Code 可读)✅ | +| 可复现执行脚本 | `demo/pr-guard-workflow.sh`(参数化)✅ | +| 真实 GitLink 项目演示 | 登录后对真实 PR 运行(见下) | +| 工作流说明 + 架构图 | 本文档 ✅ | + +--- + +## 五、真实演示步骤(登录后) + +```bash +# 1. 登录 +gitlink-cli auth login + +# 2. 找一个真实 PR +gitlink-cli pr +list --owner --repo --state open + +# 3. 跑质量看门人流水线(脚本采集,AI 在 Claude Code 做 Step2 分析) +bash demo/pr-guard-workflow.sh + +# 或在 Claude Code 里自然语言触发: +# "读 skills/gitlink-pr-guard/SKILL.md,帮我把关 / 的 PR #42" +``` + +**预期 AI 行为**:读 pr-guard SKILL.md → 按工作流跑 5 步 → 输出质量看门人报告 + 判定(通过/拒绝)+ 合并建议。 + +--- + +## 六、交付清单 + +- [x] `skills/gitlink-pr-guard/SKILL.md` — 工作流定义 + 门禁规则 + 报告模板 +- [x] `demo/pr-guard-workflow.sh` — 可复现脚本(5 步串联) +- [x] `demo/pr-guard-architecture.md` — 本文档(说明 + 架构图) +- [ ] 真实项目演示(登录后运行 + 截图/录屏) +- [ ] 报告(暂缓,后续按统一策略补《新需求构思》《变更影响测试》) diff --git a/demo/pr-guard-workflow.sh b/demo/pr-guard-workflow.sh new file mode 100644 index 0000000..48398b9 --- /dev/null +++ b/demo/pr-guard-workflow.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ============================================================ +# 代码质量看门人 · 端到端工作流脚本(子任务三) +# 串联 5 步:采集 PR → AI Review → CI 检查 → 汇总评论 → 质量判定 +# 用法:bash demo/pr-guard-workflow.sh +# 例:bash demo/pr-guard-workflow.sh myorg myproject 42 +# 前置:gitlink-cli auth login(涉及平台 API) +# 说明:采集命令真实执行;Review 分析由 AI Agent(读 pr-guard/SKILL.md)完成 +# ============================================================ + +_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="$_DIR/../gitlink-cli.exe"; [ -f "$CLI" ] || CLI="$_DIR/../gitlink-cli" # Win→.exe,Linux→无后缀 +OWNER="${1:-}"; REPO="${2:-}"; PR_ID="${3:-}" + +G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; R='\033[0;31m'; B='\033[1m'; N='\033[0m' +banner() { echo -e "\n${B}══════════════════════════════════════════════════════${N}"; echo -e "${B} $1${N}"; echo -e "${B}══════════════════════════════════════════════════════${N}"; } +step() { echo -e "\n${C}━━━ Step $1 ━━━ ${B}$2${N}"; } +ai() { echo -e "🤖 ${G}AI(读 pr-guard/SKILL.md 后):${N} $1"; } +run() { echo -e "${Y}❯ $1${N}"; eval "$1" 2>&1 | head -16; echo; } + +# ---------- 前置检查 ---------- +banner "代码质量看门人 · PR #${PR_ID:-?} 质量流水线" +[ -f "$CLI" ] || { echo -e "${R}✗ 找不到 gitlink-cli${N}"; exit 1; } +if [ -z "$OWNER" ] || [ -z "$REPO" ] || [ -z "$PR_ID" ]; then + echo -e "${R}用法: bash $0 ${N}" + echo -e "${R}例 : bash $0 myorg myproject 42${N}"; exit 1 +fi +echo -e "${G}✓${N} 目标: ${B}$OWNER/$REPO${N} PR #${B}$PR_ID${N}" + +# ---------- Step 1:采集 PR 变更 ---------- +step 1 "采集 PR 变更(pr +view / +files / +diff)" +ai "先拉 PR 详情、变更文件、diff 统计,作为审查输入。" +run "\"$CLI\" pr +view --id $PR_ID --format json" +run "\"$CLI\" pr +files --id $PR_ID --format json" +run "\"$CLI\" pr +diff --id $PR_ID --stat" + +# ---------- Step 2:AI Review(复用 code-review 逻辑)---------- +step 2 "AI Review(按 code-review 分级找问题)" +ai "逐文件分析 diff,按安全红线/错误处理/规范分级。这一步由 AI Agent 完成(读 code-review/SKILL.md)。" +echo -e " ${C}分级框架${N}:" +echo -e " 🔴 Critical:硬编码密钥 / SQL·命令注入 / 路径遍历(安全红线,阻断合并)" +echo -e " 🟡 Warning :错误处理缺失 / 边界条件 / 明文敏感信息" +echo -e " 🔵 Suggestion:命名 / 性能 / 可配置化" +echo -e " ${C}Agent 在此输出分级清单(示例见 SKILL.md 输出模板)${N}" + +# ---------- Step 3:CI 检查 ---------- +step 3 "CI 检查(ci +builds)" +ai "查 PR 对应分支的最新构建状态,作为门禁第二维。" +run "\"$CLI\" ci +builds --owner $OWNER --repo $REPO --format json" +echo -e " ${C}判定:从返回按 source_branch 匹配最新构建 → success / failure / pending${N}" + +# ---------- Step 4:发布汇总评论 ---------- +step 4 "发布质量看门人报告(api POST .../reviews)" +ai "把 Review 意见 + CI 状态 + 质量判定组装成报告,评论到 PR。" +echo -e "${Y}❯ gitlink-cli api POST /$OWNER/$REPO/pulls/$PR_ID/reviews --body '<报告>'${N}" +echo -e " ${C}报告含${N}:质量判定 + Critical/Warning 清单 + CI 状态 + 处置建议" +echo -e " ${C}[实演时此处真实发送;脚本演示仅展示结构]${N}" + +# ---------- Step 5:质量判定 ---------- +step 5 "质量判定(门禁规则 → 合并 / 请求修改)" +ai "按门禁规则决策。注意:合并是写操作,默认只建议,确认后才执行。" +echo -e " ${C}门禁规则${N}:" +echo -e " 0 Critical + CI success → ✅ 通过,建议合并" +echo -e " 有 Critical 任一 → 🔴 拒绝,请求修改" +echo -e " CI failure → 🔴 拒绝,附 CI 日志" +echo -e " 仅 Warning/Suggestion → 🟡 通过(带建议)" +echo "" +echo -e " ${G}若判定通过 + 用户确认 →${N} ${Y}gitlink-cli pr +merge --id $PR_ID --method squash${N}" + +# ---------- 总结 ---------- +banner "流水线完成" +echo -e "${B}代码质量看门人${N} 串联了 ${B}5 步${N},覆盖 ${B}4 个 CLI 域${N}:" +echo -e " pr(采集/合并)+ code-review(Review)+ ci(构建)+ api(评论)" +echo -e "\n${C}真实演示${N}:登录后对本仓库一个真实 PR 跑此脚本,由 AI 完成 Step 2 分析。" +echo -e "详见 ${Y}pr-guard-architecture.md${N}(工作流说明 + 架构图)" +read -p "按回车键继续..." diff --git a/demo/research-insight-workflow.sh b/demo/research-insight-workflow.sh new file mode 100644 index 0000000..62b04eb --- /dev/null +++ b/demo/research-insight-workflow.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# ============================================================ +# 科研仓库画像 · 端到端工作流脚本(子任务四) +# 4 步:采集数据 → 四维评分 → 协作图谱 → 科研画像报告 +# 用法:bash demo/research-insight-workflow.sh +# 例:bash demo/research-insight-workflow.sh someresearch awesome-paper-code +# 前置:gitlink-cli auth login(只读分析,不改数据) +# 说明:采集命令真实执行;四维评分 + 协作图谱由 AI(读 research-insight/SKILL.md)完成 +# ============================================================ + +_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="$_DIR/../gitlink-cli.exe"; [ -f "$CLI" ] || CLI="$_DIR/../gitlink-cli" # Win→.exe,Linux→无后缀 +OWNER="${1:-}"; REPO="${2:-}" + +G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; R='\033[0;31m'; B='\033[1m'; N='\033[0m' +banner() { echo -e "\n${B}══════════════════════════════════════════════════════${N}"; echo -e "${B} $1${N}"; echo -e "${B}══════════════════════════════════════════════════════${N}"; } +step() { echo -e "\n${C}━━━ Step $1 ━━━ ${B}$2${N}"; } +ai() { echo -e "🤖 ${G}AI(读 research-insight/SKILL.md 后):${N} $1"; } +run() { echo -e "${Y}❯ $1${N}"; eval "$1" 2>&1 | head -14; echo; } + +# ---------- 前置检查 ---------- +banner "🔬 科研仓库画像 · $OWNER/${REPO:-?}" +[ -f "$CLI" ] || { echo -e "${R}✗ 找不到 gitlink-cli${N}"; exit 1; } +if [ -z "$OWNER" ] || [ -z "$REPO" ]; then + echo -e "${R}用法: bash $0 ${N}" + echo -e "${R}例 : bash $0 someresearch awesome-paper-code${N}"; exit 1 +fi +echo -e "${G}✓${N} 分析对象: ${B}$OWNER/$REPO${N}(只读,不改数据)" + +# ---------- Step 0:fork 检测(避免给 fork 错评) ---------- +step 0 "fork 检测(引用价值要改评 upstream)" +ai "先看 repo +info 的 fork_info。是 fork 则引用价值/活跃度改评 upstream。" +INFO="$("$CLI" repo +info --owner "$OWNER" --repo "$REPO" --format json 2>/dev/null)" +UPSTREAM="$(printf '%s' "$INFO" | grep -o '"fork_project_user_login": *"[^"]*"' | head -1 | sed 's/.*: *"//;s/"$//')" +# fork_project_user_login 缺失或为 null → 非空才算 fork +[ "$UPSTREAM" = "null" ] && UPSTREAM="" +if [ -n "$UPSTREAM" ]; then + echo -e " ${R}⚠️ $OWNER/$REPO 是 ${B}$UPSTREAM/$REPO${N}${R} 的 fork —— 引用价值应改评 upstream ${B}$UPSTREAM/$REPO${N}" +else + echo -e " ${G}✓${N} 独立仓库(非 fork),正常评估" +fi + +# ---------- Step 1:采集科研仓库数据 ---------- +step 1 "采集科研仓库数据(repo / file / issue / pr + 本地 git 兜底)" +ai "拉基础画像、活跃度、合规复现性数据,作为科研评估输入。" +echo -e "${Y}❯ 基础画像(repo +info / +languages / +contributors)${N}" +"$CLI" repo +info --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -14 +"$CLI" repo +languages --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -8 +"$CLI" repo +contributors --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -12 +echo -e "\n${Y}❯ 复现性文件(file +get 读 LICENSE / CI —— 替代不存在的 repo +raw)${N}" +"$CLI" file +get --owner "$OWNER" --repo "$REPO" --path LICENSE --format json 2>&1 | head -3 +"$CLI" repo +tree --owner "$OWNER" --repo "$REPO" --path .gitea/workflows --format json 2>&1 | head -6 +echo -e "\n${Y}❯ 版本归档(release +list —— 替代不存在的 repo +tags)${N}" +"$CLI" release +list --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -6 +echo -e "\n${Y}❯ 活跃度(issue/pr + git 兜底 —— repo +commits 不存在)${N}" +"$CLI" issue +list --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -8 +"$CLI" pr +list --owner "$OWNER" --repo "$REPO" --format json 2>&1 | head -8 +echo -e " ${C}repo +commits 不存在 → 用 git clone 兜底读提交时间线${N}" +TMP="/tmp/${OWNER}-${REPO}-analyze"; rm -rf "$TMP" +if git clone --quiet --depth 100 "https://gitlink.org.cn/$OWNER/$REPO.git" "$TMP" 2>/dev/null; then + echo -e " 近 3 月提交: $(git -C "$TMP" log --oneline --since='3 months ago' 2>/dev/null | wc -l) 次" + echo -e " 最近提交 : $(git -C "$TMP" log -1 --format='%ci %an' 2>/dev/null)" + echo -e " tag 列表 : $(git -C "$TMP" tag 2>/dev/null | tr '\n' ' ')" + echo -e " PR 合并数 : $(git -C "$TMP" log --merges --oneline 2>/dev/null | wc -l)" +else + echo -e " ${R}✗ git clone 失败(无 git 或无网络)→ 活跃度改用 repo +info 计数近似${N}" +fi + +# ---------- Step 2:四维科研评分 ---------- +step 2 "四维科研评分(AI 按指标体系打分)" +ai "对采集数据按科研四维评分。这一步由 AI 完成(指标体系见 SKILL.md)。" +echo -e " ${C}🔁 可复现性${N}(科研核心,满分10):CI(+2) / 依赖锁定(+2) / 数据说明(+2) / 运行文档(+2) / 版本归档(+2)" +echo -e " ${C}📈 活跃度${N}:近3月提交频率 + Issue/PR 活跃 + 贡献者趋势" +echo -e " ${C}📑 引用价值${N}:LICENSE + 版本归档 + 文档完整 + 星标" +echo -e " ${C}🤝 协作健康${N}:Issue响应 + PR合并率 + 巴士因子(核心贡献者占比)" +echo -e " ${C}Agent 在此输出各维度得分 + 判定依据${N}" + +# ---------- Step 3:协作知识图谱 ---------- +step 3 "协作知识图谱(贡献者协作网络)" +ai "从贡献者 + PR 协作数据生成 mermaid 协作网络,呼应『知识图谱』要求。" +cat <<'MERMAID' + graph LR + A[核心贡献者1] -->|主提交| P((项目)) + B[核心贡献者2] -->|主提交| P + C[偶发贡献者] -->|贡献| P + A -.评审.-> C + B -.评审.-> C +MERMAID +echo -e " ${C}巴士因子${N}:核心贡献者提交占比 → <健康 / 单点风险>" + +# ---------- Step 4:科研画像报告 ---------- +step 4 "生成科研画像报告" +ai "组装成《科研仓库画像报告》:一句话定性 + 综合评分 + 四维详情 + 协作图 + 引用/复现/合作建议。" +echo -e " ${C}报告含${N}:🔬综合评分 / 📋基础信息 / 🔁可复现性详情 / 🤝协作网络图 / 💡给科研工作者建议" +echo -e " ${C}模板见 SKILL.md「输出模板」+ research-insight-guide.md${N}" + +# ---------- 总结 ---------- +banner "分析完成" +echo -e "${B}科研仓库画像${N} 串联 ${B}5 步${N}(fork 检测 + 采集 + 评分 + 图谱 + 报告),覆盖 CLI 域(只读):" +echo -e " repo / file / release / issue / pr + 本地 git(提交时间线兜底,repo +commits 不存在)" +echo -e "\n${C}科研视角创新${N}:可复现性评分 + 引用价值 + 协作知识图谱(区别于普通 health 工程视角)" +echo -e "${C}真实验证${N}:登录后对 GitLink 一个科研类仓库跑此脚本,由 AI 完成评分 → 产出报告 + 截图" +echo -e "详见 ${Y}research-insight-guide.md${N}(完整中文使用文档 + 报告样例)" +read -p "按回车键继续..." diff --git a/demo/snippet-live-demo.sh b/demo/snippet-live-demo.sh new file mode 100644 index 0000000..72c83b0 --- /dev/null +++ b/demo/snippet-live-demo.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# ============================================================ +# GitLink Skills 功能演示 —— snippet 完整闭环 +# 核心卖点:AI 读取 SKILL.md → 自动编排 gitlink-cli 命令 → 完成完整场景 +# 特点:snippet 是本地功能,无需登录,可安全现场实演 +# 用法:bash demo/snippet-live-demo.sh +# ============================================================ + +# 不用 set -e:保证演示连续性,关键步骤手动检查 +_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="$_DIR/../gitlink-cli.exe"; [ -f "$CLI" ] || CLI="$_DIR/../gitlink-cli" # Win→.exe,Linux→无后缀 + +# ANSI 颜色 +G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; R='\033[0;31m'; B='\033[1m'; N='\033[0m' + +banner() { echo -e "\n${B}══════════════════════════════════════════════════════${N}"; echo -e "${B} $1${N}"; echo -e "${B}══════════════════════════════════════════════════════${N}"; } +scene() { echo -e "\n${C}━━━ 场景 $1 ━━━ ${B}$2${N}"; } +user() { echo -e "🧑 ${B}用户:${N} $1"; } +ai() { echo -e "🤖 ${G}AI(读 snippet/SKILL.md 后):${N} $1"; } +show() { echo -e "${Y}❯ $1${N}"; } + +# ---------- 前置检查 ---------- +banner "GitLink Skills 演示 · snippet 闭环" +[ -f "$CLI" ] || { echo -e "${R}✗ 找不到 gitlink-cli: $CLI${N}"; exit 1; } +echo -e "${G}✓${N} gitlink-cli 就绪" +echo -e "${G}✓${N} snippet 为本地功能(${B}无需登录${N}),可安全现场演示" +echo -e "${G}✓${N} 演示数据用完即删,不污染环境" + +# ---------- 场景 1:创建 ---------- +scene 1 "保存一段常用代码" +user "帮我存一段快速排序代码,语言 python,标签 algorithm" +ai "决策 → \`snippet +create\`(⚠️ Write)。SKILL.md 规则:--title 必填、--tags 逗号分隔、--language 标注。" +show "gitlink-cli snippet +create --title '快速排序(演示)' --language python --tags algorithm,demo --content '...'" +OUT=$("$CLI" snippet +create --title '快速排序(演示)' --language python --tags algorithm,demo \ + --content 'def qs(a): return a if len(a)<2 else qs([x for x in a[1:] if x<=a[0]])+[a[0]]+qs([x for x in a[1:] if x>a[0]])' \ + --format json 2>&1) +echo "$OUT" | head -12 +DEMO_ID=$(echo "$OUT" | grep -oE '"id":[[:space:]]*"[0-9a-f]+"' | head -1 | grep -oE '[0-9a-f]{8}') +echo -e "${G}✓${N} 已创建,id = ${B}$DEMO_ID${N}" + +# ---------- 场景 2:列表 ---------- +scene 2 "浏览片段库" +user "我存了哪些片段?" +ai "决策 → \`snippet +list\`(Read)。可按 --tag / --language / --keyword 过滤。" +show "gitlink-cli snippet +list --format json" +"$CLI" snippet +list --format json 2>&1 | head -14 + +# ---------- 场景 3:搜索 ---------- +scene 3 "全文检索" +user "帮我找包含 '排序' 的片段" +ai "决策 → \`snippet +search\`(Read,全文匹配 title + content)。" +show "gitlink-cli snippet +search --query '排序' --format json" +"$CLI" snippet +search --query '排序' --format json 2>&1 | head -10 + +# ---------- 场景 4:查看详情 ---------- +scene 4 "查看指定片段" +user "看看 id=$DEMO_ID 这个的详情" +ai "决策 → \`snippet +view --id\`(Read)。" +show "gitlink-cli snippet +view --id $DEMO_ID --format json" +"$CLI" snippet +view --id "$DEMO_ID" --format json 2>&1 | head -12 + +# ---------- 场景 5:导出 ---------- +scene 5 "导出到文件复用" +user "把它导出成文件,我要贴到项目里" +ai "决策 → \`snippet +export --output\`(Read)。SKILL.md:默认输出到 stdout,-o 写文件。" +TMP="$PWD/.demo_export_$$.py" +show "gitlink-cli snippet +export --id $DEMO_ID --output $TMP" +"$CLI" snippet +export --id "$DEMO_ID" --output "$TMP" >/dev/null 2>&1 +echo -e "${G}✓${N} 已导出,文件内容:"; cat "$TMP"; rm -f "$TMP" + +# ---------- 场景 6:更新 ---------- +scene 6 "更新片段字段" +user "给这个片段补个 tag 'sort'" +ai "决策 → \`snippet +update\`(⚠️ Write)。--id 必填,至少一个字段。" +show "gitlink-cli snippet +update --id $DEMO_ID --tags algorithm,demo,sort" +"$CLI" snippet +update --id "$DEMO_ID" --tags algorithm,demo,sort --format json 2>&1 | head -8 + +# ---------- 场景 7:删除(清理)---------- +scene 7 "删除演示片段(清理)" +user "演示结束,删掉刚才的测试片段" +ai "决策 → \`snippet +delete\`(🔴 Destructive)。SKILL.md:删除不可逆,建议先 view 确认。" +show "gitlink-cli snippet +delete --id $DEMO_ID" +"$CLI" snippet +delete --id "$DEMO_ID" --format json 2>&1 | head -4 +echo -e "${G}✓${N} 演示数据已清理" + +# ---------- 总结 ---------- +banner "演示完成" +echo -e "${B}gitlink-snippet${N} Skill 的 7 个命令全部实测通过:" +echo -e " create / list / search / view / export / update / delete" +echo "" +echo -e "${B}核心价值${N}:AI 读取 SKILL.md 后,能自动编排 gitlink-cli 命令完成完整场景," +echo -e "输出严格符合 SKILL.md 定义的 envelope 格式 {\"ok\":true,\"data\":{...}}。" +echo -e "\n${C}其他 Skill(onboarding / digest / todo)涉及平台 API,登录后可按其 SKILL.md 的「工作流」演示。${N}" +read -p "按回车键继续..." \ No newline at end of file diff --git a/demo/web/README.md b/demo/web/README.md new file mode 100644 index 0000000..924d61b --- /dev/null +++ b/demo/web/README.md @@ -0,0 +1,56 @@ +# GitLink CLI 智能化能力展示(演示网页) + +一个**可交互的演示站**:点动词/敲命令 → 真跑 gitlink-cli → 显示真实输出,配合 25 域命令浏览器、48 Skill 卡片墙、pr-guard 流程、科研四维雷达,全面展示子任务一~四的成果。 + +> 位置:仓库内 `demo/web/`(`server.py` + `index.html`)。后端零依赖(仅 Python 标准库)。 + +## 架构(访客自带 token,零凭据上云) + +``` +浏览器 index.html ──fetch──▶ server.py(Python 标准库) + 顶栏 token + owner/repo │ GET /api/skill 读 SKILL.md + 命令域 / 终端 / Skill 墙 │ POST /api/run 真跑 CLI(token 透传给子进程) + pr-guard / 科研雷达 │ POST /api/analyze 四维评分 + 巴士因子 + ▼ + gitlink-cli(仓库根 ../../gitlink-cli[.exe]) +``` + +- 访客 token 仅存在**访客自己的浏览器**(localStorage),按请求传后端 → 注入子进程 `GITLINK_TOKEN` → 用完即弃,**不落服务端、不写日志**。 +- 本地命令(`snippet`/`auth`)免 token 即可真跑;平台命令(`repo`/`issue`/`pr`…)需访客填自己的 token。 + +## 本地启动(3 步) + +```bash +# 1. 在仓库根编译 CLI(已有可跳过) +cd gitlink-cli # 仓库根(含 go.mod) +go build -o gitlink-cli . # Windows 会生成 gitlink-cli.exe + +# 2. 启动后端(零依赖) +cd demo/web +python server.py # → http://0.0.0.0:8000 + +# 3. 浏览器打开 http://localhost:8000 +# 顶栏粘自己的 GitLink token(auth login --token 拿)→ 平台命令即可真跑 +``` + +> 服务端会自动探测二进制:`GITLINK_BIN` 环境变量 > 仓库根 `gitlink-cli`/`gitlink-cli.exe` > PATH。 +> 端口/主机可设:`PORT=9000 HOST=127.0.0.1 python server.py`。 + +## 展示区 + +| 区块 | 内容 | +|------|------| +| ① 命令全域浏览器 | 25 域 160+ 动词,按子任务分组 + 搜索;点动词填终端真跑 | +| ② Skill 全集 | 48 个 Skill 卡片(按 全部/新增/科研/质量 筛选),点开读 SKILL.md 全文 | +| ③ pr-guard | 5 步门禁动画 + 「用真实 PR 跑」(填 token) | +| ④ 科研画像 | 「实拉分析」目标仓库 → 四维雷达 + 协作网络 + 巴士因子 | +| ⑤ 验证 | 命令层 / 编排层 / 输出层 三层证据 | + +## 安全 + +- 后端白名单(仅 30 个 gitlink-cli 顶层域)+ subprocess 列表参数(不经 shell)+ 30s 超时。 +- 访客 token 不落服务端。公网部署也**不烘焙任何团队 token**。 + +## 云端部署 + +见上级 [`demo/README.md`](../README.md)(Dockerfile + `.devops` 流水线 + 服务器部署说明)。 diff --git a/demo/web/index.html b/demo/web/index.html new file mode 100644 index 0000000..6130867 --- /dev/null +++ b/demo/web/index.html @@ -0,0 +1,424 @@ + + + + + +GitLink CLI · 智能化能力展示 + + + + + + + + +
+
+
+

🚀 GitLink CLI · 智能化能力展示

+

从「开发者工具」升级为「AI 可驱动平台」—— 命令 · Skills · 工作流 · 科研辅助

+
+
+ 🛠 25 域 / 160+ 命令 + 🧠 53 Skills + 🚪 pr-guard 工作流 + 🔬 科研四维画像 +
+
+
+ 🔑 GitLink Token + + 📦 默认目标 + + / + + +
+
+ +
+ + +
+

▶ 命令浏览器 (先选大方向 → 展开域 → 点动词填参数 → 真跑)

+

左侧点分类展开命令域;点动词在右侧「参数构建」里填参(搜索类命令的 keyword 等由你决定),回车或点▶运行。本地命令免 token。

+
+ +
+
+

命令域

+ +
+
+
+ +
+ +
+
+ 🧩 参数构建 + 点左侧动词开始 +
+
+
+ $ + + +
+
+ +
+ + 终端 +
+
+
+ $ + +
+
+
+
+ + +
+
+

🧠 Skill 全集 (53 个 · 点卡片读 SKILL.md 全文)

+
+ + + + +
+
+
+
+ + +
+

🚪 任务三:代码质量看门人

pr-guard
+
+

是什么:PR 提交后自动跑完 5 步质量门禁 —— 串接 pr → code-review → ci → api → pr 四个命令域。

+

门禁规则0 Critical + CI success → ✅ 合并;否则 🔴 拒绝

+

与 code-review 区别:code-review 只做 Review;pr-guard 是完整闭环(采集→审查→CI→评论→判定/合并)。

+

任务三全家桶(不止 pr-guard):code-review×pr-summary 闭环、community-ops-sweep 七段式周报(wauxing)+ 自动化 Skill 族 gatekeeper / commit-quality / issue-triage / issueops / release-auto / wiki-builder / pipeline-guardian / webhook-sentinel(点下方 Skill 墙查看)。

+
+
+
+ + +
+
+
+ + +
+

🔬 任务四:科研仓库画像

research-insight
+
+

是什么:四维科研评分(🔁可复现性 / 📈活跃度 / 📑引用价值 / 🤝协作健康)+ 贡献者协作网络 + 巴士因子。

+

区别于 health:health 看「工程维护好不好」,本工具看「科研上值不值得引用/复现」;含 fork 检测(fork 自动改评 upstream)。

+

任务四全家桶(whale 主导,S1–S6 全生命周期):research-insight(S1) / research-graph(S2) / compliance(S3) / collab-match(S4) / research-progress(S5) / research-visual(S6) + research-fork-impact/scholar-profile;Python 算法层 ~5800 行 + Go Web demo。

+

真实验证whale_hihihi/gitlink-cli → 识别为 fork、巴士因子 38%、可复现性 8/10。

+
+
+ 分析对象 = 顶栏 owner/repo + + (公开仓库可免 token) +
+
+
+
+

协作网络图 (点「实拉分析」用真实贡献者重绘)

+ +
+
+
+
+ + +
+

✅ 验证:三层证据(对照模板法)

+
+

命令层

命令真实执行,返回真实数据(非 unknown/401)

+

编排层

AI 按 SKILL.md 的顺序调命令

+

输出层

输出符合 SKILL.md 模板(判定/评分/清单)

+
+
+ +
+ GitLink CLI 智能化能力展示 · 子任务一~四 + DevOps · 后端 demo/web/server.py(访客自带 token,零凭据上云) +
+
+ + + + + + diff --git a/demo/web/server.py b/demo/web/server.py new file mode 100644 index 0000000..744a3d9 --- /dev/null +++ b/demo/web/server.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +gitlink-cli 演示网页 · 后端(零依赖,仅 Python 标准库) +作用:接收前端命令 + 访客 token,真跑 gitlink-cli,返回真实输出。 +启动:python server.py → http://0.0.0.0:$PORT (默认 8000) + +位置:仓库内 demo/web/server.py。CLI 在仓库根 ../../gitlink-cli[.exe]。 +安全:本地/演示用。已做白名单(只允许 gitlink-cli 子命令)、subprocess 列表参数(不经 shell)、 + 30s 超时。访客 token 仅在请求内存中传给子进程,不写日志、不落盘。 +""" +import http.server +import json +import os +import re +import socketserver +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +# Windows GBK 终端兼容 +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +PORT = int(os.getenv("PORT", "8000")) +HOST = os.getenv("HOST", "0.0.0.0") # 云端需 0.0.0.0;只听本机设 HOST=127.0.0.1 +ROOT = Path(__file__).resolve().parent # .../demo/web +REPO_ROOT = ROOT.parent.parent # 仓库根 .../gitlink-cli + + +def _find_cli(): + """CLI 位置:GITLINK_BIN > 仓库内 > PATH。Linux=gitlink-cli,Windows=gitlink-cli.exe。""" + env = os.getenv("GITLINK_BIN") + if env and Path(env).exists(): + return Path(env) + for name in ("gitlink-cli", "gitlink-cli.exe"): + p = REPO_ROOT / name + if p.exists(): + return p + for d in os.getenv("PATH", "").split(os.pathsep): + p = Path(d) / "gitlink-cli" + if p.exists(): + return p + return REPO_ROOT / "gitlink-cli" # 占位 + + +CLI = _find_cli() +CWD = str(REPO_ROOT) # 让 --owner/--repo 可从 git remote 自动解析 + +ALLOWED_DOMAINS = { # 全部 30 个顶层域 + "api", "auth", "branch", "ci", "compare", "config", "dataset", "doctor", + "file", "health", "ignore", "issue", "label", "license", "member", + "milestone", "org", "pipeline", "pm", "pr", "profile", "release", "repo", + "search", "snippet", "user", "version", "webhook", "wiki", "workflow", +} +NEEDS_TOKEN_DOMAINS = { # 平台域(访客需填 token) + "repo", "issue", "pr", "branch", "release", "search", "label", "member", + "milestone", "webhook", "wiki", "org", "user", "ci", "compare", "dataset", + "health", "license", "pipeline", "pm", "profile", "workflow", "api", +} +LOCAL_DOMAINS = {"snippet", "auth", "config", "version", "doctor"} + + +def _run_cli(args, token="", timeout=30): + env = dict(os.environ) + if token: + env["GITLINK_TOKEN"] = token + r = subprocess.run( + [str(CLI)] + args, capture_output=True, text=True, timeout=timeout, + cwd=CWD, encoding="utf-8", errors="replace", env=env, + ) + return r.stdout, r.stderr, r.returncode + + +def _parse_json(stdout): + s = stdout.strip() + i, j = s.find("{"), s.rfind("}") + if i < 0 or j < 0: + return None + try: + return json.loads(s[i:j + 1]) + except Exception: + return None + + +def _license_name(text): + t = (text or "").lower() + if "mulan" in t: return "Mulan PSL v2" + if t.startswith("mit") or "mit license" in t: return "MIT" + if "apache" in t: return "Apache 2.0" + if "gpl" in t: return "GPL" + if "bsd" in t: return "BSD" + return "有 LICENSE" if text else "无" + + +def analyze_repo(owner, repo, token): + """采集 + 按 research-insight 评分表算四维 + 巴士因子。""" + def cli(*a): + return _run_cli(list(a), token=token, timeout=30) + + info_o, _, _ = cli("repo", "+info", "--owner", owner, "--repo", repo, "--format", "json") + info = (_parse_json(info_o) or {}).get("data") or {} + + contrib_o, _, _ = cli("repo", "+contributors", "--owner", owner, "--repo", repo, "--format", "json") + contribs = ((_parse_json(contrib_o) or {}).get("data") or {}).get("list") or [] + contribs_sorted = sorted(contribs, key=lambda c: -(c.get("contributions") or 0)) + + rel_o, _, _ = cli("release", "+list", "--owner", owner, "--repo", repo, "--format", "json") + releases = ((_parse_json(rel_o) or {}).get("data") or {}).get("releases") or [] + + lic_o, _, _ = cli("file", "+get", "--owner", owner, "--repo", repo, "--path", "LICENSE", "--format", "json") + lic_text = "" + lic_parsed = _parse_json(lic_o) + if lic_parsed: + d = lic_parsed.get("data") or {} + entries = d.get("entries") if isinstance(d, dict) else None + if isinstance(entries, dict): + lic_text = entries.get("content") or "" + elif isinstance(d, str): + lic_text = d + + ci_o, _, _ = cli("repo", "+tree", "--owner", owner, "--repo", repo, "--path", ".gitea/workflows", "--format", "json") + ci_entries = ((_parse_json(ci_o) or {}).get("data") or {}).get("entries") or [] + has_ci = bool(ci_entries) + + tree_o, _, _ = cli("repo", "+tree", "--owner", owner, "--repo", repo, "--format", "json") + root_files = [str(e.get("name", "")) for e in ((_parse_json(tree_o) or {}).get("data") or {}).get("entries") or []] + lock_files = {"go.sum", "package-lock.json", "yarn.lock", "Cargo.lock", "requirements.txt", "poetry.lock", "pom.xml"} + has_lock = any(f in lock_files for f in root_files) + has_readme = any(f.lower().startswith("readme") for f in root_files) + + # 可复现性(工程类,满分 8:数据项 N/A) + repro, repro_detail = 0, [] + repro += 2 if has_ci else 0; repro_detail.append(("CI 配置", has_ci)) + repro += 2 if has_lock else 0; repro_detail.append(("依赖锁定", has_lock)) + repro += 2 if has_readme else 0; repro_detail.append(("运行文档", has_readme)) + ver = bool(releases or info.get("version_releases_count")) + repro += 2 if ver else 0; repro_detail.append(("版本归档", ver)) + + n_contrib = len(contribs) + activity = min(10, round(n_contrib / 3)) if n_contrib else 2 # 无 commits API,用贡献者规模近似 + + citation = 0 + citation += 3 if lic_text else 0 + citation += 3 if ver else 0 + citation += 2 if has_readme else 0 + citation += 2 if (info.get("fork_info") or {}).get("fork_project_user_login") else 0 + citation = min(10, citation) + + top_perc = 0.0 + if contribs_sorted: + try: + top_perc = float(re.sub(r"[^\d.]", "", str(contribs_sorted[0].get("contribution_perc", "0")))) + except Exception: + top_perc = 0.0 + collab = 10 if top_perc < 33 else (6 if top_perc < 50 else 3) + + fork_from = (info.get("fork_info") or {}).get("fork_project_user_login") + return { + "ok": True, "owner": owner, "repo": repo, + "is_fork": bool(fork_from), "fork_from": fork_from, + "name": info.get("name", repo), + "license": _license_name(lic_text), + "contributor_count": n_contrib, + "release_count": len(releases), + "version_releases_count": info.get("version_releases_count", 0), + "contributors": [ + {"name": c.get("name") or c.get("login") or "?", + "contributions": c.get("contributions", 0), + "perc": c.get("contribution_perc", "")} + for c in contribs_sorted[:8] + ], + "scores": {"repro": repro, "activity": activity, "citation": citation, "collab": collab}, + "repro_max": 8, "repro_detail": repro_detail, + "bus_factor": top_perc, + "bus_risk": "低" if top_perc < 33 else ("中" if top_perc < 50 else "高"), + } + + +class Handler(http.server.BaseHTTPRequestHandler): + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def do_OPTIONS(self): + self.send_response(204); self._cors(); self.end_headers() + + def do_GET(self): + path = urlparse(self.path).path + if path in ("/", "/index.html"): + self._serve_file("index.html", "text/html") + elif path == "/api/cli": + self._json({"ok": True, "cli": str(CLI), "exists": CLI.exists()}) + elif path == "/api/domains": + self._json({"ok": True, "domains": sorted(ALLOWED_DOMAINS), "local": sorted(LOCAL_DOMAINS)}) + elif path == "/api/health": + self._json({"ok": True, "cli_exists": CLI.exists()}) + elif path == "/api/skill": + self._handle_skill() + else: + self.send_error(404) + + def _handle_skill(self): + q = parse_qs(urlparse(self.path).query) + name = (q.get("name") or [""])[0].strip() + if not name: + self._json({"ok": False, "error": "缺少 ?name="}); return + skill_md = REPO_ROOT / "skills" / f"gitlink-{name}" / "SKILL.md" + if not skill_md.exists(): + self._json({"ok": False, "error": f"找不到 SKILL.md:gitlink-{name}"}); return + self._json({"ok": True, "name": name, "content": skill_md.read_text(encoding="utf-8")}) + + def do_POST(self): + path = urlparse(self.path).path + body = self._read_body() + if path == "/api/run": + self._handle_run(body) + elif path == "/api/analyze": + owner = (body.get("owner") or "").strip() + repo = (body.get("repo") or "").strip() + token = (body.get("token") or "").strip() + if not owner or not repo: + self._json({"ok": False, "error": "缺少 owner/repo"}); return + try: + self._json(analyze_repo(owner, repo, token)) + except subprocess.TimeoutExpired: + self._json({"ok": False, "error": "采集超时(>30s)"}) + except Exception as e: + self._json({"ok": False, "error": str(e)}) + else: + self.send_error(404) + + def _handle_run(self, body): + cmd = (body.get("cmd") or "").strip() + token = (body.get("token") or "").strip() + if not cmd: + self._json({"ok": False, "error": "空命令"}); return + args = cmd.split() + while args and args[0] in ("gitlink-cli", "gitlink-cli.exe", "./gitlink-cli.exe"): + args = args[1:] + if not args: + self._json({"ok": False, "error": "缺少子命令"}); return + domain = args[0] + if domain not in ALLOWED_DOMAINS: + self._json({"ok": False, "error": f"不允许的命令:{domain}(仅限 gitlink-cli 子命令)"}); return + needs_token = domain in NEEDS_TOKEN_DOMAINS + try: + out, err, code = _run_cli(args, token=token, timeout=30) + # 失败时从 stderr 取首行作为 error,前端绝不再显示 undefined + err_msg = None + if code != 0: + first = (err.strip() or out.strip()).splitlines() + err_msg = first[0][:200] if first else f"命令失败(退出码 {code})" + self._json({ + "ok": code == 0, "cmd": f"gitlink-cli {' '.join(args)}", + "stdout": out, "stderr": err, "code": code, "error": err_msg, + "needs_token": needs_token, "token_provided": bool(token), + }) + except subprocess.TimeoutExpired: + self._json({"ok": False, "error": "命令超时(>30s),可能涉及交互输入"}) + except Exception as e: + self._json({"ok": False, "error": str(e)}) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0) or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + return json.loads(raw) + except Exception: + return {} + + def _json(self, obj): + data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self._cors() + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _serve_file(self, name, mime): + p = ROOT / name + if not p.exists(): + self.send_error(404, f"{name} 不存在"); return + data = p.read_bytes() + self.send_response(200) + self.send_header("Content-Type", f"{mime}; charset=utf-8") + self._cors() + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *a): + pass + + +class ReuseTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True # 每个请求独立线程,单个卡死不阻塞其他请求 + + +if __name__ == "__main__": + if not CLI.exists(): + print(f"[!] 找不到 gitlink-cli 二进制:{CLI}") + print(" 请先编译:cd <仓库根> && go build -o gitlink-cli . (Linux)") + print(" 或设环境变量 GITLINK_BIN 指向已有二进制。") + with ReuseTCPServer((HOST, PORT), Handler) as httpd: + print(f"[OK] gitlink-cli 演示后端已启动:http://{HOST}:{PORT}") + print(f" CLI:{CLI}(exists={CLI.exists()})") + print(f" 访客在网页顶栏填自己的 GitLink token 即可跑平台命令。Ctrl+C 停止。") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n已停止") diff --git a/gitlink-cli.exe b/gitlink-cli.exe new file mode 100644 index 0000000..3ebb873 Binary files /dev/null and b/gitlink-cli.exe differ diff --git a/gitlink-cli.exe~ b/gitlink-cli.exe~ new file mode 100644 index 0000000..a63624d Binary files /dev/null and b/gitlink-cli.exe~ differ diff --git a/internal/client/client.go b/internal/client/client.go index 1fb9d80..59c276e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -41,19 +41,34 @@ func New() (*Client, error) { }, nil } +// Do makes an API call with automatic .json suffix appended. func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { path = normalizeAPIPath(c.BaseURL, path) + return c.do(method, path, body, query, true, "json") +} - // Append .json suffix if not already present (GitLink API convention) - // Handle paths that may already contain query strings (e.g., /path?key=val) - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if shouldAppendJSONSuffix(basePath) { - path = basePath + ".json" + queryStr +// DoRaw makes an API call without appending .json suffix. +func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + return c.do(method, path, body, query, false, "json") +} + +// DoForm makes an API call with form-encoded body (no .json suffix). +// Used for Wiki and other endpoints that expect application/x-www-form-urlencoded. +func (c *Client) DoForm(method, path string, body url.Values, query url.Values) (*output.Envelope, error) { + return c.do(method, path, body, query, false, "form") +} + +func (c *Client) do(method, path string, body interface{}, query url.Values, appendJSON bool, encoding string) (*output.Envelope, error) { + if appendJSON { + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if shouldAppendJSONSuffix(basePath) { + path = basePath + ".json" + queryStr + } + } else if shouldAppendJSONSuffix(path) { + path += ".json" } - } else if shouldAppendJSONSuffix(path) { - path += ".json" } fullURL := c.BaseURL + path if len(query) > 0 { @@ -64,14 +79,26 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o fullURL += sep + query.Encode() } - // Replace path params + var bodyData []byte var bodyReader io.Reader + var contentType string if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, err + if encoding == "form" { + formValues, ok := body.(url.Values) + if !ok { + return nil, fmt.Errorf("DoForm requires url.Values body") + } + bodyData = []byte(formValues.Encode()) + contentType = "application/x-www-form-urlencoded" + } else { + var err error + bodyData, err = json.Marshal(body) + if err != nil { + return nil, err + } + contentType = "application/json" } - bodyReader = bytes.NewReader(data) + bodyReader = bytes.NewReader(bodyData) } req, err := http.NewRequest(method, fullURL, bodyReader) @@ -79,8 +106,15 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return nil, err } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if c.Debug { fmt.Printf("→ %s %s\n", method, fullURL) + if bodyData != nil { + fmt.Printf(" body: %s\n", string(bodyData)) + } } resp, err := c.HTTP.Do(req) @@ -98,7 +132,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) } - // Check HTTP-level errors if resp.StatusCode >= 400 { return nil, &APIError{ StatusCode: resp.StatusCode, @@ -107,10 +140,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } - // Parse JSON var raw map[string]interface{} if err := json.Unmarshal(respData, &raw); err != nil { - // Not JSON, return as-is return output.SuccessEnvelope(string(respData), nil), nil } @@ -147,7 +178,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } - // Auto-parse JSON string data (GitLink API quirk: some endpoints return data as JSON string) if dataStr, ok := raw["data"].(string); ok { var parsedData interface{} if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil { @@ -155,7 +185,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } - // Build meta from pagination info var meta *output.Meta if tc, ok := raw["total_count"]; ok { meta = &output.Meta{} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..8131a1f 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -34,12 +34,15 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", - "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", - "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", - "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", - "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "cmd.issue.batch_assign.short": "Assign issues to a user or via CSV", + "cmd.issue.batch_close.long": "Close multiple issues. Pass issue numbers via --numbers or read from a CSV via --from.\n\nDefaults to dry-run; pass --confirm to execute.", + "cmd.issue.batch_close.short": "Close multiple issues by issue numbers or a CSV file", + "cmd.issue.batch_create.short": "Create multiple issues from CSV", + "cmd.issue.batch_delete.short": "Batch delete multiple issues (use with caution)", + "cmd.issue.batch_label.long": "Batch add, remove, or set labels on multiple issues.\n\nDefaults to dry-run; pass --confirm to execute.", + "cmd.issue.batch_label.short": "Add, remove, or set labels on multiple issues", + "cmd.issue.batch_open.short": "Reopen multiple issues", + "cmd.issue.batch_update.short": "Update multiple issues via CSV or --ids", "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", @@ -90,12 +93,19 @@ "cmd.repo.tree.short": "List repository files and directories", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.search.issues.short": "Search issues in a repository", "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", + "cmd.user.headmaps.short": "Show user contribution heatmap", "cmd.user.info.short": "Show user profile", "cmd.user.me.short": "Show current authenticated user", "cmd.user.short": "User operations", + "cmd.user.stats_activity.short": "Show user activity statistics", + "cmd.user.stats_develop.short": "Show user development capability", + "cmd.user.stats_major.short": "Show user major positioning", + "cmd.user.stats_role.short": "Show user role positioning", + "cmd.user.trends.short": "Show user project activity trends", "cmd.version.short": "Print version information", "cmd.webhook.create.short": "Create a repository webhook", "cmd.webhook.delete.short": "Delete a repository webhook", @@ -150,13 +160,36 @@ "flag.issue.assignee": "Assignee login", "flag.issue.assignee_id": "Assignee user ID", "flag.issue.author_id": "Author user ID", - "flag.issue.batch.reason": "Optional reason shown in the batch result", - "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", - "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", - "flag.issue.batch_close.state": "Filter by issue state before closing", - "flag.issue.batch_label.state": "Filter by issue state", - "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", - "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.batch.confirm": "Confirm batch operation", + "flag.issue.batch.delay": "Delay in milliseconds between requests", + "flag.issue.batch.dry_run": "Preview the issues that would be changed without making any changes", + "flag.issue.batch.from": "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header", + "flag.issue.batch.label": "Filter by label name", + "flag.issue.batch.max": "Maximum number of issues to process", + "flag.issue.batch.numbers": "Comma-separated issue numbers from the web URL, for example: 1,2,3", + "flag.issue.batch.search": "Search issues by keyword", + "flag.issue.batch.state": "Filter by state: open, closed, all", + "flag.issue.batch_assign.assignee": "Assignee username or ID (uniform mode)", + "flag.issue.batch_assign.csv": "CSV file with number,assignee columns", + "flag.issue.batch_assign.numbers": "Comma-separated issue numbers", + "flag.issue.batch_close.state": "Filter by state before closing", + "flag.issue.batch_create.csv": "CSV file with issue data", + "flag.issue.batch_create.print_schema": "Print CSV header and exit", + "flag.issue.batch_delete.confirm": "Confirm deletion (required for safety)", + "flag.issue.batch_delete.dry_run": "Preview deletion without executing", + "flag.issue.batch_delete.ids": "Comma-separated issue IDs", + "flag.issue.batch_label.action": "Action: add, remove, or set", + "flag.issue.batch_label.csv": "CSV file path with issue numbers", + "flag.issue.batch_label.label_ids": "Comma-separated label IDs (mutually exclusive with --labels)", + "flag.issue.batch_label.labels": "Comma-separated label names", + "flag.issue.batch_label.numbers": "Comma-separated issue numbers", + "flag.issue.batch_update.assignees": "Comma-separated assignee user IDs", + "flag.issue.batch_update.csv": "CSV file path with updates", + "flag.issue.batch_update.ids": "Comma-separated issue IDs", + "flag.issue.batch_update.milestone": "Milestone ID", + "flag.issue.batch_update.priority": "Priority ID", + "flag.issue.batch_update.status": "New status: open or closed", + "flag.issue.batch_update.tags": "Comma-separated label/tag IDs", "flag.issue.body": "Issue description", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", @@ -214,6 +247,11 @@ "flag.repo.private": "Make repository private (true/false)", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", + "flag.search.issues.assignee": "Filter by assignee user ID", + "flag.search.issues.author": "Filter by author user ID", + "flag.search.issues.category": "Issue category: all, opened, closed", + "flag.search.issues.milestone": "Filter by milestone ID", + "flag.search.issues.tag": "Filter by tag IDs (comma-separated)", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..d41273e 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -34,12 +34,15 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", - "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", - "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", - "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", - "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", - "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", - "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "cmd.issue.batch_assign.short": "批量指派议题负责人", + "cmd.issue.batch_close.long": "批量关闭多个议题。支持 --numbers 直接传入编号,或 --from 从 CSV 读取。\n\n默认 dry-run,传入 --confirm 后执行。", + "cmd.issue.batch_close.short": "批量关闭多个议题(按编号或 CSV)", + "cmd.issue.batch_create.short": "从 CSV 批量创建议题", + "cmd.issue.batch_delete.short": "批量删除多个议题(谨慎使用)", + "cmd.issue.batch_label.long": "批量给多个议题添加、移除或设置标签。\n\n默认 dry-run,传入 --confirm 后执行。", + "cmd.issue.batch_label.short": "批量管理议题标签(添加/移除/设置)", + "cmd.issue.batch_open.short": "批量重新打开多个议题", + "cmd.issue.batch_update.short": "批量更新议题(按 CSV 或 ID)", "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", @@ -90,12 +93,19 @@ "cmd.repo.tree.short": "列出仓库文件和目录", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.search.issues.short": "搜索仓库议题", "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", + "cmd.user.headmaps.short": "显示用户贡献热力图", "cmd.user.info.short": "显示用户资料", "cmd.user.me.short": "显示当前认证用户", "cmd.user.short": "用户操作", + "cmd.user.stats_activity.short": "显示用户活动统计", + "cmd.user.stats_develop.short": "显示用户开发能力", + "cmd.user.stats_major.short": "显示用户专业/学科定位", + "cmd.user.stats_role.short": "显示用户角色定位", + "cmd.user.trends.short": "显示用户项目活动趋势", "cmd.version.short": "打印版本信息", "cmd.webhook.create.short": "创建仓库 Webhook", "cmd.webhook.delete.short": "删除仓库 Webhook", @@ -150,13 +160,36 @@ "flag.issue.assignee": "负责人登录名", "flag.issue.assignee_id": "负责人用户 ID", "flag.issue.author_id": "作者用户 ID", - "flag.issue.batch.reason": "批量结果中显示的可选原因", - "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", - "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch.confirm": "确认执行批量操作", + "flag.issue.batch.delay": "请求间隔(毫秒)", + "flag.issue.batch.dry_run": "预览将被修改的议题,不实际执行", + "flag.issue.batch.from": "从 CSV 文件读取议题编号(支持 number/issue_number/project_issues_index 列)", + "flag.issue.batch.label": "按标签名筛选", + "flag.issue.batch.max": "最多处理的议题数", + "flag.issue.batch.numbers": "议题编号列表(逗号分隔),例如 1,2,3", + "flag.issue.batch.search": "按关键词搜索议题", + "flag.issue.batch.state": "按状态筛选:open、closed、all", + "flag.issue.batch_assign.assignee": "负责人用户名或 ID(统一模式)", + "flag.issue.batch_assign.csv": "CSV 文件(含 number,assignee 列)", + "flag.issue.batch_assign.numbers": "议题编号列表(逗号分隔)", "flag.issue.batch_close.state": "关闭前按议题状态筛选", - "flag.issue.batch_label.state": "按议题状态筛选", - "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", - "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.batch_create.csv": "含议题数据的 CSV 文件", + "flag.issue.batch_create.print_schema": "打印 CSV 表头并退出", + "flag.issue.batch_delete.confirm": "确认删除(安全必需)", + "flag.issue.batch_delete.dry_run": "预览删除,不实际执行", + "flag.issue.batch_delete.ids": "议题 ID 列表(逗号分隔)", + "flag.issue.batch_label.action": "操作类型:add、remove 或 set", + "flag.issue.batch_label.csv": "含议题编号的 CSV 文件路径", + "flag.issue.batch_label.label_ids": "标签 ID 列表(逗号分隔,与 --labels 互斥)", + "flag.issue.batch_label.labels": "标签名列表(逗号分隔)", + "flag.issue.batch_label.numbers": "议题编号列表(逗号分隔)", + "flag.issue.batch_update.assignees": "负责人用户 ID 列表(逗号分隔)", + "flag.issue.batch_update.csv": "含更新数据的 CSV 文件路径", + "flag.issue.batch_update.ids": "议题 ID 列表(逗号分隔)", + "flag.issue.batch_update.milestone": "里程碑 ID", + "flag.issue.batch_update.priority": "优先级 ID", + "flag.issue.batch_update.status": "新状态:open 或 closed", + "flag.issue.batch_update.tags": "标签/标记 ID 列表(逗号分隔)", "flag.issue.body": "议题描述", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", @@ -214,6 +247,11 @@ "flag.repo.private": "设为私有仓库(true/false)", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", + "flag.search.issues.assignee": "按负责人用户 ID 筛选", + "flag.search.issues.author": "按作者用户 ID 筛选", + "flag.search.issues.category": "议题分类:all、opened、closed", + "flag.search.issues.milestone": "按里程碑 ID 筛选", + "flag.search.issues.tag": "按标签 ID 筛选(逗号分隔)", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..c3651d4 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -66,6 +66,11 @@ func printTable(w io.Writer, envelope *Envelope) error { return nil } + // Detect and render diff data in git-diff style + if isDiffData(envelope.Data) { + return printDiffTable(w, envelope) + } + // Try to render as table if data is a slice of maps switch data := envelope.Data.(type) { case []interface{}: @@ -178,3 +183,99 @@ func formatValue(v interface{}) string { return fmt.Sprintf("%v", v) } } + +// isDiffData checks whether the envelope data is a PR diff response with sections. +// Distinguishes from the simpler files listing by checking for sections in files. +func isDiffData(data interface{}) bool { + m, ok := data.(map[string]interface{}) + if !ok { + return false + } + files, hasFiles := m["files"].([]interface{}) + if !hasFiles || len(files) == 0 { + return false + } + // Diff data has files with "sections"; simple file listing does not. + firstFile, ok := files[0].(map[string]interface{}) + if !ok { + return false + } + _, hasSections := firstFile["sections"] + return hasSections +} + +// printDiffTable renders diff data in git-diff style text output. +func printDiffTable(w io.Writer, envelope *Envelope) error { + data, ok := envelope.Data.(map[string]interface{}) + if !ok { + return printJSON(w, envelope) + } + + files, ok := data["files"].([]interface{}) + if !ok { + return printJSON(w, envelope) + } + + // Summary header + fileNums, _ := data["file_nums"].(float64) + totalAdd, _ := data["total_addition"].(float64) + totalDel, _ := data["total_deletion"].(float64) + fmt.Fprintf(w, " %d files changed, %d insertions(+), %d deletions(-)\n\n", + int(fileNums), int(totalAdd), int(totalDel)) + + for _, f := range files { + fm, ok := f.(map[string]interface{}) + if !ok { + continue + } + + name, _ := fm["name"].(string) + addition, _ := fm["addition"].(float64) + deletion, _ := fm["deletion"].(float64) + + // File header + fmt.Fprintf(w, "diff --git a/%s b/%s\n", name, name) + + if isCreated, _ := fm["is_created"].(bool); isCreated { + fmt.Fprintf(w, "new file\n") + } + if isDeleted, _ := fm["is_deleted"].(bool); isDeleted { + fmt.Fprintf(w, "deleted file\n") + } + + fmt.Fprintf(w, "--- a/%s\n", name) + fmt.Fprintf(w, "+++ b/%s\n", name) + fmt.Fprintf(w, "@@ +%d -%d @@\n", int(addition), int(deletion)) + + // Render each line + sections, _ := fm["sections"].([]interface{}) + for _, sec := range sections { + secMap, ok := sec.(map[string]interface{}) + if !ok { + continue + } + lines, _ := secMap["lines"].([]interface{}) + for _, l := range lines { + lineMap, ok := l.(map[string]interface{}) + if !ok { + continue + } + content, _ := lineMap["content"].(string) + lineType, _ := lineMap["type"].(float64) + + switch int(lineType) { + case 4: // diff hunk header + fmt.Fprintf(w, "%s\n", content) + case 2: // addition + fmt.Fprintf(w, "%s\n", content) + case 3: // deletion + fmt.Fprintf(w, "%s\n", content) + default: // context line + fmt.Fprintf(w, "%s\n", content) + } + } + } + fmt.Fprintln(w) + } + return nil +} diff --git a/internal/snippet/store.go b/internal/snippet/store.go new file mode 100644 index 0000000..bb34e96 --- /dev/null +++ b/internal/snippet/store.go @@ -0,0 +1,89 @@ +package snippet + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "time" +) + +// Snippet represents a locally stored code snippet. +type Snippet struct { + ID string `json:"id"` + Title string `json:"title"` + Language string `json:"language"` + Tags []string `json:"tags"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SnippetStore manages snippet persistence in a JSON file. +type SnippetStore struct { + FilePath string +} + +// NewSnippetStore creates a store pointing at the default path: +// ~/.config/gitlink-cli/snippets.json +// Respects GITLINK_CONFIG_DIR env var. +func NewSnippetStore() *SnippetStore { + dir := os.Getenv("GITLINK_CONFIG_DIR") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config", "gitlink-cli") + } + return &SnippetStore{ + FilePath: filepath.Join(dir, "snippets.json"), + } +} + +// NewSnippetStoreWithPath creates a store with an explicit file path. +// Used in tests to point at temp directories. +func NewSnippetStoreWithPath(path string) *SnippetStore { + return &SnippetStore{FilePath: path} +} + +// Load reads all snippets from the JSON file. +// Returns an empty slice (not error) if the file does not exist. +func (s *SnippetStore) Load() ([]Snippet, error) { + data, err := os.ReadFile(s.FilePath) + if err != nil { + if os.IsNotExist(err) { + return []Snippet{}, nil + } + return nil, err + } + if len(data) == 0 { + return []Snippet{}, nil + } + var snippets []Snippet + if err := json.Unmarshal(data, &snippets); err != nil { + return nil, err + } + if snippets == nil { + return []Snippet{}, nil + } + return snippets, nil +} + +// Save writes all snippets to the JSON file. +// Creates parent directories if needed. +func (s *SnippetStore) Save(snippets []Snippet) error { + if err := os.MkdirAll(filepath.Dir(s.FilePath), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(snippets, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.FilePath, data, 0o644) +} + +// GenerateID creates a random 8-character hex ID. +func GenerateID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/snippet/store_test.go b/internal/snippet/store_test.go new file mode 100644 index 0000000..206c9f4 --- /dev/null +++ b/internal/snippet/store_test.go @@ -0,0 +1,121 @@ +package snippet + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadReturnsEmptyOnMissingFile(t *testing.T) { + dir := t.TempDir() + store := NewSnippetStoreWithPath(filepath.Join(dir, "snippets.json")) + + snippets, err := store.Load() + if err != nil { + t.Fatalf("Load on missing file should not error: %v", err) + } + if len(snippets) != 0 { + t.Fatalf("expected empty slice, got %d items", len(snippets)) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + store := NewSnippetStoreWithPath(filepath.Join(dir, "snippets.json")) + + now := time.Now().Truncate(time.Second) + original := []Snippet{ + { + ID: "abc12345", + Title: "Hello World", + Language: "go", + Tags: []string{"test", "example"}, + Content: `fmt.Println("hello")`, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "def67890", + Title: "HTTP Handler", + Language: "go", + Tags: []string{"http"}, + Content: `func handler(w http.ResponseWriter, r *http.Request) {}`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + if err := store.Save(original); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := store.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 snippets, got %d", len(loaded)) + } + if loaded[0].ID != "abc12345" { + t.Errorf("ID mismatch: got %s", loaded[0].ID) + } + if loaded[0].Title != "Hello World" { + t.Errorf("Title mismatch: got %s", loaded[0].Title) + } + if loaded[0].Language != "go" { + t.Errorf("Language mismatch: got %s", loaded[0].Language) + } + if len(loaded[0].Tags) != 2 || loaded[0].Tags[0] != "test" { + t.Errorf("Tags mismatch: got %v", loaded[0].Tags) + } + if loaded[0].Content != `fmt.Println("hello")` { + t.Errorf("Content mismatch: got %s", loaded[0].Content) + } + if !loaded[0].CreatedAt.Equal(now) { + t.Errorf("CreatedAt mismatch: got %v, want %v", loaded[0].CreatedAt, now) + } +} + +func TestSaveCreatesDirectory(t *testing.T) { + dir := t.TempDir() + nestedPath := filepath.Join(dir, "a", "b", "c", "snippets.json") + store := NewSnippetStoreWithPath(nestedPath) + + err := store.Save([]Snippet{}) + if err != nil { + t.Fatalf("Save to nested path failed: %v", err) + } + if _, err := os.Stat(nestedPath); os.IsNotExist(err) { + t.Fatal("file was not created") + } +} + +func TestGenerateID(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := GenerateID() + if len(id) != 8 { + t.Errorf("ID length should be 8, got %d: %s", len(id), id) + } + if ids[id] { + t.Errorf("duplicate ID generated: %s", id) + } + ids[id] = true + } +} + +func TestLoadEmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "snippets.json") + os.WriteFile(path, []byte(""), 0o644) + + store := NewSnippetStoreWithPath(path) + snippets, err := store.Load() + if err != nil { + t.Fatalf("Load empty file should not error: %v", err) + } + if len(snippets) != 0 { + t.Fatalf("expected empty slice, got %d", len(snippets)) + } +} diff --git a/pr-test-file.txt b/pr-test-file.txt new file mode 100644 index 0000000..d848ff9 --- /dev/null +++ b/pr-test-file.txt @@ -0,0 +1 @@ +PR Test 2026年 4月 7日 星期二 11时45分56秒 CST diff --git a/research-output/session_20260707_203518/hotspot/hotspot.json b/research-output/session_20260707_203518/hotspot/hotspot.json new file mode 100644 index 0000000..b33638c --- /dev/null +++ b/research-output/session_20260707_203518/hotspot/hotspot.json @@ -0,0 +1,389 @@ +{ + "scenario": "hotspot", + "keywords": [ + "深度学习" + ], + "category": "", + "trending_repos": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 3, + "forks": 1, + "visits": 592, + "score": 64, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-07-13", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "CocytusXRS/AI_test", + "description": "", + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "visits": 548, + "score": 54, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2022-12-02", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "AIEven/AIkun", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 382, + "score": 38, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2025-08-09", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "shenyan/machineLearningTemplate", + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "language": "Python", + "stars": 1, + "forks": 0, + "visits": 309, + "score": 31, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-10-22", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "opensci/pDeep", + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 292, + "score": 29, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "dongeliu/dlvc", + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 276, + "score": 27, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "Hua135/sam-optimizers", + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失值和损失锐度,有效提升了模型的泛化能力,但其计算开销约为传统优化器的两倍。本文系统研究了SAM及其两种高效变体——ESA", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 64, + "score": 26, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-23", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 39, + "score": 23, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-27", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "clj111/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 186, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-06-16", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "njuiselab/Gandalf", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 189, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2024-10-11", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ly15927029790/CodeRecommendation", + "description": "代码生成式补全工具", + "language": "Java", + "stars": 0, + "forks": 0, + "visits": 177, + "score": 17, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2020-10-12", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "co63oc/mcTVM", + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦GPU的软件生态,为开发者提供便捷、高效的深度学习模型部署解决方案,适用于人工智能、异构计算等相关领域的研发与应用场景。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 66, + "score": 16, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-04-15", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "aaasdasd/aa", + "description": "", + "language": "Python3.6", + "stars": 0, + "forks": 0, + "visits": 146, + "score": 14, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2023-07-15", + "contributors_count": 0, + "releases_count": 0 + } + ], + "active_discussions": [], + "topic_heat": [ + { + "topic": "deep_learning", + "count": 15 + }, + { + "topic": "scientific_computing", + "count": 7 + }, + { + "topic": "computer_vision", + "count": 6 + }, + { + "topic": "machine_learning", + "count": 4 + }, + { + "topic": "database", + "count": 3 + }, + { + "topic": "time_series", + "count": 3 + }, + { + "topic": "generative_ai", + "count": 3 + }, + { + "topic": "reinforcement_learning", + "count": 2 + }, + { + "topic": "nlp", + "count": 2 + }, + { + "topic": "autonomous_systems", + "count": 2 + } + ], + "core_scholars": [ + { + "login": "Edge", + "repo_count": 2, + "repos": [ + "Edgedev/Edge-Computing-Engine", + "clj111/Edge-Computing-Engine" + ] + }, + { + "login": "junru shao", + "repo_count": 2, + "repos": [ + "co63oc/mcTVM" + ] + }, + { + "login": "Edgedev", + "repo_count": 1, + "repos": [ + "Edgedev/Edge-Computing-Engine" + ] + }, + { + "login": "cloudy1225", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "CocytusXRS", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "AIEven", + "repo_count": 1, + "repos": [ + "AIEven/AIkun" + ] + }, + { + "login": "shenyan", + "repo_count": 1, + "repos": [ + "shenyan/machineLearningTemplate" + ] + }, + { + "login": "jalew", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "wen-feng zeng", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "dong liu", + "repo_count": 1, + "repos": [ + "dongeliu/dlvc" + ] + }, + { + "login": "404notfound233", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + }, + { + "login": "beginner401", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + } + ], + "core_teams": [ + { + "login": "Edgedev", + "repo_count": 1 + }, + { + "login": "CocytusXRS", + "repo_count": 1 + }, + { + "login": "AIEven", + "repo_count": 1 + }, + { + "login": "shenyan", + "repo_count": 1 + }, + { + "login": "opensci", + "repo_count": 1 + }, + { + "login": "dongeliu", + "repo_count": 1 + }, + { + "login": "clj111", + "repo_count": 1 + }, + { + "login": "njuiselab", + "repo_count": 1 + } + ], + "meta": { + "repo_count": 13, + "issue_count": 2, + "pr_count": 0, + "scholar_count": 35, + "discussion_count": 0, + "topic_count": 10 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203518/hotspot/report.md b/research-output/session_20260707_203518/hotspot/report.md new file mode 100644 index 0000000..66b8b30 --- /dev/null +++ b/research-output/session_20260707_203518/hotspot/report.md @@ -0,0 +1,50 @@ +# 🔬 科研热点追踪报告 + +> 关键词:深度学习 +> 扫描时间:2026-07-07 +> 覆盖仓库:13 个 · 讨论 0 条 · 主题 10 个 · 学者 35 位 + +## 🔥 飙升项目 Top 5 + +| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 | +|---|------|------|---------|--------|--------|------|------| +| 1 | `Edgedev/Edge-Computing-Engine` | C++ | 592 | 3 | 1 | 64 | 2021-07-13 | +| 2 | `CocytusXRS/AI_test` | Jupyter notebook | 548 | 0 | 0 | 54 | 2022-12-02 | +| 3 | `AIEven/AIkun` | Python | 382 | 0 | 0 | 38 | 2025-08-09 | +| 4 | `shenyan/machineLearningTemplate` | Python | 309 | 1 | 0 | 31 | 2021-10-22 | +| 5 | `opensci/pDeep` | Python | 292 | 0 | 0 | 29 | 2021-03-19 | + +## 📊 热门主题 + +- **deep_learning** — 15 个仓库 ███████████████ +- **scientific_computing** — 7 个仓库 ███████ +- **computer_vision** — 6 个仓库 ██████ +- **machine_learning** — 4 个仓库 ████ +- **database** — 3 个仓库 ███ +- **time_series** — 3 个仓库 ███ +- **generative_ai** — 3 个仓库 ███ +- **reinforcement_learning** — 2 个仓库 ██ +- **nlp** — 2 个仓库 ██ +- **autonomous_systems** — 2 个仓库 ██ + +## 👥 核心学者 + +- **Edge** — 关联 2 个仓库 +- **junru shao** — 关联 2 个仓库 +- **Edgedev** — 关联 1 个仓库 +- **cloudy1225** — 关联 1 个仓库 +- **CocytusXRS** — 关联 1 个仓库 + +## 🏛 活跃组织/团队 + +- **Edgedev** — 1 个仓库 +- **CocytusXRS** — 1 个仓库 +- **AIEven** — 1 个仓库 +- **shenyan** — 1 个仓库 +- **opensci** — 1 个仓库 +- **dongeliu** — 1 个仓库 +- **clj111** — 1 个仓库 +- **njuiselab** — 1 个仓库 + +--- +*由 gitlink-research-hotspot 自动生成* \ No newline at end of file diff --git a/research-output/session_20260707_203632/hotspot/hotspot.json b/research-output/session_20260707_203632/hotspot/hotspot.json new file mode 100644 index 0000000..b33638c --- /dev/null +++ b/research-output/session_20260707_203632/hotspot/hotspot.json @@ -0,0 +1,389 @@ +{ + "scenario": "hotspot", + "keywords": [ + "深度学习" + ], + "category": "", + "trending_repos": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 3, + "forks": 1, + "visits": 592, + "score": 64, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-07-13", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "CocytusXRS/AI_test", + "description": "", + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "visits": 548, + "score": 54, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2022-12-02", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "AIEven/AIkun", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 382, + "score": 38, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2025-08-09", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "shenyan/machineLearningTemplate", + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "language": "Python", + "stars": 1, + "forks": 0, + "visits": 309, + "score": 31, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-10-22", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "opensci/pDeep", + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 292, + "score": 29, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "dongeliu/dlvc", + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 276, + "score": 27, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "Hua135/sam-optimizers", + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失值和损失锐度,有效提升了模型的泛化能力,但其计算开销约为传统优化器的两倍。本文系统研究了SAM及其两种高效变体——ESA", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 64, + "score": 26, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-23", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 39, + "score": 23, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-27", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "clj111/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 186, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-06-16", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "njuiselab/Gandalf", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 189, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2024-10-11", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ly15927029790/CodeRecommendation", + "description": "代码生成式补全工具", + "language": "Java", + "stars": 0, + "forks": 0, + "visits": 177, + "score": 17, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2020-10-12", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "co63oc/mcTVM", + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦GPU的软件生态,为开发者提供便捷、高效的深度学习模型部署解决方案,适用于人工智能、异构计算等相关领域的研发与应用场景。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 66, + "score": 16, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-04-15", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "aaasdasd/aa", + "description": "", + "language": "Python3.6", + "stars": 0, + "forks": 0, + "visits": 146, + "score": 14, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2023-07-15", + "contributors_count": 0, + "releases_count": 0 + } + ], + "active_discussions": [], + "topic_heat": [ + { + "topic": "deep_learning", + "count": 15 + }, + { + "topic": "scientific_computing", + "count": 7 + }, + { + "topic": "computer_vision", + "count": 6 + }, + { + "topic": "machine_learning", + "count": 4 + }, + { + "topic": "database", + "count": 3 + }, + { + "topic": "time_series", + "count": 3 + }, + { + "topic": "generative_ai", + "count": 3 + }, + { + "topic": "reinforcement_learning", + "count": 2 + }, + { + "topic": "nlp", + "count": 2 + }, + { + "topic": "autonomous_systems", + "count": 2 + } + ], + "core_scholars": [ + { + "login": "Edge", + "repo_count": 2, + "repos": [ + "Edgedev/Edge-Computing-Engine", + "clj111/Edge-Computing-Engine" + ] + }, + { + "login": "junru shao", + "repo_count": 2, + "repos": [ + "co63oc/mcTVM" + ] + }, + { + "login": "Edgedev", + "repo_count": 1, + "repos": [ + "Edgedev/Edge-Computing-Engine" + ] + }, + { + "login": "cloudy1225", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "CocytusXRS", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "AIEven", + "repo_count": 1, + "repos": [ + "AIEven/AIkun" + ] + }, + { + "login": "shenyan", + "repo_count": 1, + "repos": [ + "shenyan/machineLearningTemplate" + ] + }, + { + "login": "jalew", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "wen-feng zeng", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "dong liu", + "repo_count": 1, + "repos": [ + "dongeliu/dlvc" + ] + }, + { + "login": "404notfound233", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + }, + { + "login": "beginner401", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + } + ], + "core_teams": [ + { + "login": "Edgedev", + "repo_count": 1 + }, + { + "login": "CocytusXRS", + "repo_count": 1 + }, + { + "login": "AIEven", + "repo_count": 1 + }, + { + "login": "shenyan", + "repo_count": 1 + }, + { + "login": "opensci", + "repo_count": 1 + }, + { + "login": "dongeliu", + "repo_count": 1 + }, + { + "login": "clj111", + "repo_count": 1 + }, + { + "login": "njuiselab", + "repo_count": 1 + } + ], + "meta": { + "repo_count": 13, + "issue_count": 2, + "pr_count": 0, + "scholar_count": 35, + "discussion_count": 0, + "topic_count": 10 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/hotspot/report.md b/research-output/session_20260707_203632/hotspot/report.md new file mode 100644 index 0000000..66b8b30 --- /dev/null +++ b/research-output/session_20260707_203632/hotspot/report.md @@ -0,0 +1,50 @@ +# 🔬 科研热点追踪报告 + +> 关键词:深度学习 +> 扫描时间:2026-07-07 +> 覆盖仓库:13 个 · 讨论 0 条 · 主题 10 个 · 学者 35 位 + +## 🔥 飙升项目 Top 5 + +| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 | +|---|------|------|---------|--------|--------|------|------| +| 1 | `Edgedev/Edge-Computing-Engine` | C++ | 592 | 3 | 1 | 64 | 2021-07-13 | +| 2 | `CocytusXRS/AI_test` | Jupyter notebook | 548 | 0 | 0 | 54 | 2022-12-02 | +| 3 | `AIEven/AIkun` | Python | 382 | 0 | 0 | 38 | 2025-08-09 | +| 4 | `shenyan/machineLearningTemplate` | Python | 309 | 1 | 0 | 31 | 2021-10-22 | +| 5 | `opensci/pDeep` | Python | 292 | 0 | 0 | 29 | 2021-03-19 | + +## 📊 热门主题 + +- **deep_learning** — 15 个仓库 ███████████████ +- **scientific_computing** — 7 个仓库 ███████ +- **computer_vision** — 6 个仓库 ██████ +- **machine_learning** — 4 个仓库 ████ +- **database** — 3 个仓库 ███ +- **time_series** — 3 个仓库 ███ +- **generative_ai** — 3 个仓库 ███ +- **reinforcement_learning** — 2 个仓库 ██ +- **nlp** — 2 个仓库 ██ +- **autonomous_systems** — 2 个仓库 ██ + +## 👥 核心学者 + +- **Edge** — 关联 2 个仓库 +- **junru shao** — 关联 2 个仓库 +- **Edgedev** — 关联 1 个仓库 +- **cloudy1225** — 关联 1 个仓库 +- **CocytusXRS** — 关联 1 个仓库 + +## 🏛 活跃组织/团队 + +- **Edgedev** — 1 个仓库 +- **CocytusXRS** — 1 个仓库 +- **AIEven** — 1 个仓库 +- **shenyan** — 1 个仓库 +- **opensci** — 1 个仓库 +- **dongeliu** — 1 个仓库 +- **clj111** — 1 个仓库 +- **njuiselab** — 1 个仓库 + +--- +*由 gitlink-research-hotspot 自动生成* \ No newline at end of file diff --git a/research-output/session_20260707_203632/inspire/inspire.json b/research-output/session_20260707_203632/inspire/inspire.json new file mode 100644 index 0000000..4b39fa2 --- /dev/null +++ b/research-output/session_20260707_203632/inspire/inspire.json @@ -0,0 +1,131 @@ +{ + "scenario": "inspire", + "mode": "repo", + "repo": "caoweiqiong/zwf", + "gap_topics": [ + "computer_vision", + "generative_ai", + "devops", + "database", + "security" + ], + "needed_languages": [ + "Batchfile", + "CSS", + "HTML", + "JavaScript", + "Python", + "Shell", + "TypeScript", + "python", + "r", + "typescript" + ], + "gap_signals": [], + "candidates": [ + { + "login": "dev", + "score": 17.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "LegendC", + "score": 16.2, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "typescript" + ], + "repo_count": 2, + "reasons": [ + "语言匹配: typescript", + "本仓库活跃贡献者" + ] + }, + { + "login": "huster42", + "score": 15.4, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "whale", + "score": 14.6, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "pxz2fgtcv", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pepox4cvf", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [ + "markdown" + ], + "repo_count": 1, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pfmy8cv3n", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "mcvzf42hi", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + } + ], + "innovation_points": [], + "idea": null, + "llm_used": false +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/inspire/report.md b/research-output/session_20260707_203632/inspire/report.md new file mode 100644 index 0000000..666f64d --- /dev/null +++ b/research-output/session_20260707_203632/inspire/report.md @@ -0,0 +1,19 @@ +# 💡 创新启发报告 + +> 焦点仓库:`caoweiqiong/zwf` +> LLM 建议:⏭ 未启用(无 DEEPSEEK_API_KEY) + +## 🎯 缺口主题 +`computer_vision`, `generative_ai`, `devops`, `database`, `security` + +## 🤝 可合作学者 Top 5 +| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 | +|---|---|---|---|---|---| +| `dev` | 17.8 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| `LegendC` | 16.2 | 0.0 | 0.1 | high | 语言匹配: typescript;本仓库活跃贡献者 | +| `huster42` | 15.4 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| `whale` | 14.6 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| `pxz2fgtcv` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | + +--- +*由 gitlink-research-inspire 生成* \ No newline at end of file diff --git a/research-output/session_20260707_203632/knowledge-graph/graph.dot b/research-output/session_20260707_203632/knowledge-graph/graph.dot new file mode 100644 index 0000000..a951966 --- /dev/null +++ b/research-output/session_20260707_203632/knowledge-graph/graph.dot @@ -0,0 +1,226 @@ +digraph G { + rankdir=LR; + graph [fontname="Helvetica"]; + node [fontname="Helvetica", style="filled"]; + edge [fontname="Helvetica"]; + repo_AIEven_AIkun [label="AIEven/AIkun", fillcolor="#4C78A8"]; + repo_CocytusXRS_AI_test [label="CocytusXRS/AI_test", fillcolor="#4C78A8"]; + repo_Edgedev_Edge_Computing_Engine [label="Edgedev/Edge-Computing-Engine", fillcolor="#4C78A8"]; + repo_Hua135_sam_optimizers [label="Hua135/sam-optimizers", fillcolor="#4C78A8"]; + repo_aaasdasd_aa [label="aaasdasd/aa", fillcolor="#4C78A8"]; + repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise [label="ashh/jittor-chanshiguan-track2-straightpcf-denoise", fillcolor="#4C78A8"]; + repo_clj111_Edge_Computing_Engine [label="clj111/Edge-Computing-Engine", fillcolor="#4C78A8"]; + repo_co63oc_mcTVM [label="co63oc/mcTVM", fillcolor="#4C78A8"]; + repo_dongeliu_dlvc [label="dongeliu/dlvc", fillcolor="#4C78A8"]; + repo_ly15927029790_CodeRecommendation [label="ly15927029790/CodeRecommendation", fillcolor="#4C78A8"]; + repo_njuiselab_Gandalf [label="njuiselab/Gandalf", fillcolor="#4C78A8"]; + repo_opensci_pDeep [label="opensci/pDeep", fillcolor="#4C78A8"]; + repo_shenyan_machineLearningTemplate [label="shenyan/machineLearningTemplate", fillcolor="#4C78A8"]; + scholar_404notfound233 [label="404notfound233", fillcolor="#F58518"]; + scholar_AIEven [label="AIEven", fillcolor="#F58518"]; + scholar_CocytusXRS [label="CocytusXRS", fillcolor="#F58518"]; + scholar_Edge [label="Edge", fillcolor="#F58518"]; + scholar_Edgedev [label="Edgedev", fillcolor="#F58518"]; + scholar_Hua135 [label="Hua135", fillcolor="#F58518"]; + scholar_aaasdasd [label="aaasdasd", fillcolor="#F58518"]; + scholar_andrew reusch [label="andrew reusch", fillcolor="#F58518"]; + scholar_ash [label="ash", fillcolor="#F58518"]; + scholar_ashh [label="ashh", fillcolor="#F58518"]; + scholar_beginner401 [label="beginner401", fillcolor="#F58518"]; + scholar_cloudy1225 [label="cloudy1225", fillcolor="#F58518"]; + scholar_cody yu [label="cody yu", fillcolor="#F58518"]; + scholar_dong liu [label="dong liu", fillcolor="#F58518"]; + scholar_driazati [label="driazati", fillcolor="#F58518"]; + scholar_eric lunderberg [label="eric lunderberg", fillcolor="#F58518"]; + scholar_gandalf401 [label="gandalf401", fillcolor="#F58518"]; + scholar_haichen shen [label="haichen shen", fillcolor="#F58518"]; + scholar_jalew [label="jalew", fillcolor="#F58518"]; + scholar_junru shao [label="junru shao", fillcolor="#F58518"]; + scholar_krzysztof parzyszek [label="krzysztof parzyszek", fillcolor="#F58518"]; + scholar_luke hutton [label="luke hutton", fillcolor="#F58518"]; + scholar_ly15927029790 [label="ly15927029790", fillcolor="#F58518"]; + scholar_masahi [label="masahi", fillcolor="#F58518"]; + scholar_matthew brookhart [label="matthew brookhart", fillcolor="#F58518"]; + scholar_mehrdad hessar [label="mehrdad hessar", fillcolor="#F58518"]; + scholar_ruihang lai [label="ruihang lai", fillcolor="#F58518"]; + scholar_dong liu -> repo_dongeliu_dlvc [label="contributes_to"]; + scholar_eric lunderberg -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_eric lunderberg -> scholar_andrew reusch [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_cody yu [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_driazati [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_haichen shen [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_junru shao [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_luke hutton [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_masahi [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_eric lunderberg -> scholar_ruihang lai [label="collaborates_with"]; + scholar_masahi -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_masahi -> scholar_andrew reusch [label="collaborates_with"]; + scholar_masahi -> scholar_cody yu [label="collaborates_with"]; + scholar_masahi -> scholar_driazati [label="collaborates_with"]; + scholar_masahi -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_masahi -> scholar_haichen shen [label="collaborates_with"]; + scholar_masahi -> scholar_junru shao [label="collaborates_with"]; + scholar_masahi -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_masahi -> scholar_luke hutton [label="collaborates_with"]; + scholar_masahi -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_masahi -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_masahi -> scholar_ruihang lai [label="collaborates_with"]; + scholar_ruihang lai -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_ruihang lai -> scholar_andrew reusch [label="collaborates_with"]; + scholar_ruihang lai -> scholar_cody yu [label="collaborates_with"]; + scholar_ruihang lai -> scholar_driazati [label="collaborates_with"]; + scholar_ruihang lai -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_ruihang lai -> scholar_haichen shen [label="collaborates_with"]; + scholar_ruihang lai -> scholar_junru shao [label="collaborates_with"]; + scholar_ruihang lai -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_ruihang lai -> scholar_luke hutton [label="collaborates_with"]; + scholar_ruihang lai -> scholar_masahi [label="collaborates_with"]; + scholar_ruihang lai -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_ruihang lai -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_driazati -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_driazati -> scholar_andrew reusch [label="collaborates_with"]; + scholar_driazati -> scholar_cody yu [label="collaborates_with"]; + scholar_driazati -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_driazati -> scholar_haichen shen [label="collaborates_with"]; + scholar_driazati -> scholar_junru shao [label="collaborates_with"]; + scholar_driazati -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_driazati -> scholar_luke hutton [label="collaborates_with"]; + scholar_driazati -> scholar_masahi [label="collaborates_with"]; + scholar_driazati -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_driazati -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_driazati -> scholar_ruihang lai [label="collaborates_with"]; + scholar_krzysztof parzyszek -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_krzysztof parzyszek -> scholar_andrew reusch [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_cody yu [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_driazati [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_haichen shen [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_junru shao [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_luke hutton [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_masahi [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_krzysztof parzyszek -> scholar_ruihang lai [label="collaborates_with"]; + scholar_mehrdad hessar -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_mehrdad hessar -> scholar_andrew reusch [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_cody yu [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_driazati [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_haichen shen [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_junru shao [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_luke hutton [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_masahi [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_mehrdad hessar -> scholar_ruihang lai [label="collaborates_with"]; + scholar_junru shao -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_junru shao -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_junru shao -> scholar_andrew reusch [label="collaborates_with"]; + scholar_junru shao -> scholar_cody yu [label="collaborates_with"]; + scholar_junru shao -> scholar_driazati [label="collaborates_with"]; + scholar_junru shao -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_junru shao -> scholar_haichen shen [label="collaborates_with"]; + scholar_junru shao -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_junru shao -> scholar_luke hutton [label="collaborates_with"]; + scholar_junru shao -> scholar_masahi [label="collaborates_with"]; + scholar_junru shao -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_junru shao -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_junru shao -> scholar_ruihang lai [label="collaborates_with"]; + scholar_matthew brookhart -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_matthew brookhart -> scholar_andrew reusch [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_cody yu [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_driazati [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_haichen shen [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_junru shao [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_luke hutton [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_masahi [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_matthew brookhart -> scholar_ruihang lai [label="collaborates_with"]; + scholar_haichen shen -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_haichen shen -> scholar_andrew reusch [label="collaborates_with"]; + scholar_haichen shen -> scholar_cody yu [label="collaborates_with"]; + scholar_haichen shen -> scholar_driazati [label="collaborates_with"]; + scholar_haichen shen -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_haichen shen -> scholar_junru shao [label="collaborates_with"]; + scholar_haichen shen -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_haichen shen -> scholar_luke hutton [label="collaborates_with"]; + scholar_haichen shen -> scholar_masahi [label="collaborates_with"]; + scholar_haichen shen -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_haichen shen -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_haichen shen -> scholar_ruihang lai [label="collaborates_with"]; + scholar_luke hutton -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_luke hutton -> scholar_andrew reusch [label="collaborates_with"]; + scholar_luke hutton -> scholar_cody yu [label="collaborates_with"]; + scholar_luke hutton -> scholar_driazati [label="collaborates_with"]; + scholar_luke hutton -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_luke hutton -> scholar_haichen shen [label="collaborates_with"]; + scholar_luke hutton -> scholar_junru shao [label="collaborates_with"]; + scholar_luke hutton -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_luke hutton -> scholar_masahi [label="collaborates_with"]; + scholar_luke hutton -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_luke hutton -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_luke hutton -> scholar_ruihang lai [label="collaborates_with"]; + scholar_andrew reusch -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_andrew reusch -> scholar_cody yu [label="collaborates_with"]; + scholar_andrew reusch -> scholar_driazati [label="collaborates_with"]; + scholar_andrew reusch -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_andrew reusch -> scholar_haichen shen [label="collaborates_with"]; + scholar_andrew reusch -> scholar_junru shao [label="collaborates_with"]; + scholar_andrew reusch -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_andrew reusch -> scholar_luke hutton [label="collaborates_with"]; + scholar_andrew reusch -> scholar_masahi [label="collaborates_with"]; + scholar_andrew reusch -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_andrew reusch -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_andrew reusch -> scholar_ruihang lai [label="collaborates_with"]; + scholar_cody yu -> repo_co63oc_mcTVM [label="contributes_to"]; + scholar_cody yu -> scholar_andrew reusch [label="collaborates_with"]; + scholar_cody yu -> scholar_driazati [label="collaborates_with"]; + scholar_cody yu -> scholar_eric lunderberg [label="collaborates_with"]; + scholar_cody yu -> scholar_haichen shen [label="collaborates_with"]; + scholar_cody yu -> scholar_junru shao [label="collaborates_with"]; + scholar_cody yu -> scholar_krzysztof parzyszek [label="collaborates_with"]; + scholar_cody yu -> scholar_luke hutton [label="collaborates_with"]; + scholar_cody yu -> scholar_masahi [label="collaborates_with"]; + scholar_cody yu -> scholar_matthew brookhart [label="collaborates_with"]; + scholar_cody yu -> scholar_mehrdad hessar [label="collaborates_with"]; + scholar_cody yu -> scholar_ruihang lai [label="collaborates_with"]; + scholar_jalew -> repo_opensci_pDeep [label="contributes_to"]; + scholar_Edge -> repo_Edgedev_Edge_Computing_Engine [label="contributes_to"]; + scholar_Edge -> repo_clj111_Edge_Computing_Engine [label="contributes_to"]; + scholar_Edge -> scholar_Edgedev [label="collaborates_with"]; + scholar_Edgedev -> repo_Edgedev_Edge_Computing_Engine [label="contributes_to"]; + scholar_Edgedev -> repo_Edgedev_Edge_Computing_Engine [label="owns"]; + scholar_Edgedev -> scholar_Edge [label="collaborates_with"]; + scholar_AIEven -> repo_AIEven_AIkun [label="contributes_to"]; + scholar_AIEven -> repo_AIEven_AIkun [label="owns"]; + scholar_aaasdasd -> repo_aaasdasd_aa [label="contributes_to"]; + scholar_aaasdasd -> repo_aaasdasd_aa [label="owns"]; + scholar_404notfound233 -> repo_njuiselab_Gandalf [label="contributes_to"]; + scholar_404notfound233 -> scholar_beginner401 [label="collaborates_with"]; + scholar_404notfound233 -> scholar_gandalf401 [label="collaborates_with"]; + scholar_beginner401 -> repo_njuiselab_Gandalf [label="contributes_to"]; + scholar_beginner401 -> scholar_404notfound233 [label="collaborates_with"]; + scholar_beginner401 -> scholar_gandalf401 [label="collaborates_with"]; + scholar_gandalf401 -> repo_njuiselab_Gandalf [label="contributes_to"]; + scholar_gandalf401 -> scholar_404notfound233 [label="collaborates_with"]; + scholar_gandalf401 -> scholar_beginner401 [label="collaborates_with"]; + scholar_cloudy1225 -> repo_CocytusXRS_AI_test [label="contributes_to"]; + scholar_cloudy1225 -> scholar_CocytusXRS [label="collaborates_with"]; + scholar_CocytusXRS -> repo_CocytusXRS_AI_test [label="contributes_to"]; + scholar_CocytusXRS -> repo_CocytusXRS_AI_test [label="owns"]; + scholar_CocytusXRS -> scholar_cloudy1225 [label="collaborates_with"]; + scholar_ly15927029790 -> repo_ly15927029790_CodeRecommendation [label="contributes_to"]; + scholar_ly15927029790 -> repo_ly15927029790_CodeRecommendation [label="owns"]; + scholar_Hua135 -> repo_Hua135_sam_optimizers [label="contributes_to"]; + scholar_Hua135 -> repo_Hua135_sam_optimizers [label="owns"]; + scholar_ash -> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise [label="contributes_to"]; + scholar_ash -> scholar_ashh [label="collaborates_with"]; + scholar_ashh -> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise [label="contributes_to"]; + scholar_ashh -> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise [label="owns"]; + scholar_ashh -> scholar_ash [label="collaborates_with"]; +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/knowledge-graph/graph.json b/research-output/session_20260707_203632/knowledge-graph/graph.json new file mode 100644 index 0000000..791a6af --- /dev/null +++ b/research-output/session_20260707_203632/knowledge-graph/graph.json @@ -0,0 +1,4278 @@ +{ + "scenario": "S2_research_knowledge_graph", + "keywords": [ + "深度学习" + ], + "nodes": [ + { + "id": "repo:dongeliu/dlvc", + "type": "repo", + "label": "dongeliu/dlvc", + "props": { + "language": "C++", + "stars": 0, + "forks": 0, + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "readme_head": "This software package is the reference software for Deep Learning-Based Video Coding (DLVC). The reference software includes both encoder and decoder functionality.\n\nReference software is useful in ai" + } + }, + { + "id": "repo:co63oc/mcTVM", + "type": "repo", + "label": "co63oc/mcTVM", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦GPU的软件生态,为开发者提供便捷、高效的深度学习模型部署解决方案,适用于人工智能、异构计算等相关领域的研发与应用场景。", + "readme_head": "" + } + }, + { + "id": "repo:opensci/pDeep", + "type": "repo", + "label": "opensci/pDeep", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "readme_head": "# pDeep\nPredicting MS/MS Spectra of Peptides with Deep Learning\n\nPlease visit https://github.com/pFindStudio/pDeep/tree/master/pDeep2 for the improved version of pDeep --- pDeep2.\n" + } + }, + { + "id": "repo:Edgedev/Edge-Computing-Engine", + "type": "repo", + "label": "Edgedev/Edge-Computing-Engine", + "props": { + "language": "C++", + "stars": 4, + "forks": 1, + "description": "Edge : 一个开源的科学计算引擎", + "readme_head": "\n\n
\n# Edge-Engine\n\n\n\n## Edge : 一个开源的科学计算引擎\n\n[README for English_version](./RE" + } + }, + { + "id": "repo:clj111/Edge-Computing-Engine", + "type": "repo", + "label": "clj111/Edge-Computing-Engine", + "props": { + "language": "C++", + "stars": 0, + "forks": 0, + "description": "Edge : 一个开源的科学计算引擎", + "readme_head": "#### 从命令行创建一个新的仓库\nnew line\n\n```bash\ntouch README.md\ngit init\ngit add README.md\ngit commit -m \"first commit\"\ngit remote add origin https://git.trustie.net/Edge/Edge-Computing-Engine.git\ngit push -u ori" + } + }, + { + "id": "repo:AIEven/AIkun", + "type": "repo", + "label": "AIEven/AIkun", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "", + "readme_head": "# Frequency-domain-deep-learning\n## 频域深度学习入门教程\n\n## 概述\n\n这是一个用于在频域进行深度学习的入门教程,看完这个文档后,希望读者能够理解和掌握频域深度学习的核心概念、技术和应用。\n\n## 📖 适用人群\n\n- **初学者**: 对频域处理感兴趣的深度学习爱好者\n- **研究人员**: 希望将频域方法应用到研究中的学者\n- **工程师**: 需要在实际项" + } + }, + { + "id": "repo:aaasdasd/aa", + "type": "repo", + "label": "aaasdasd/aa", + "props": { + "language": "Python3.6", + "stars": 0, + "forks": 0, + "description": "", + "readme_head": "# aa\n\n" + } + }, + { + "id": "repo:njuiselab/Gandalf", + "type": "repo", + "label": "njuiselab/Gandalf", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "", + "readme_head": "# Gandalf\nSome codes and results about our paper, here is the structure of our directory:\n\n- **/src:** High level interface of out implementation of model generator across frameworks by JSON\n\n - conf" + } + }, + { + "id": "repo:CocytusXRS/AI_test", + "type": "repo", + "label": "CocytusXRS/AI_test", + "props": { + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "description": "", + "readme_head": "\n\n## 自动化测试 —— 基于等价融合算子的深度学习框架差分测试技术 \n\n\n\n| 姓名 | 学号 |\n| ------ | --------- |\n| 徐润石 | 201250167 |\n| 于欣博 | 201250165 |\n| 刘云辉 | 201250166 |\n| 桂金鑫 | 201850107 |\n\n\n\n### 1. 深度学习框架选择 —— PyTorch\n\n#### " + } + }, + { + "id": "repo:ly15927029790/CodeRecommendation", + "type": "repo", + "label": "ly15927029790/CodeRecommendation", + "props": { + "language": "Java", + "stars": 0, + "forks": 0, + "description": "代码生成式补全工具", + "readme_head": "## 项目概述:\n\n基于上下文分析和深度学习的代码生成式补全工具DeepAPIRec是一款由复旦大学软件工程实验室CodeWisdom团队推出的基于代码上下文和深度学习的智能化API代码推荐工具。DeepAPIRec考虑代码上下文中的API使用代码及其结构信息,并通过结合Child-Sum Tree-LSTM网络和N-ary LSTM网络作为API推荐的深度学习模型。此外,DeepAPIRec构建" + } + }, + { + "id": "repo:Hua135/sam-optimizers", + "type": "repo", + "label": "Hua135/sam-optimizers", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失值和损失锐度,有效提升了模型的泛化能力,但其计算开销约为传统优化器的两倍。本文系统研究了SAM及其两种高效变体——ESA", + "readme_head": "# sam-optimizers\n# 新型优化算法的实现与分析 - SAM系列优化器\n\n本项目实现了多种基于锐度感知最小化(Sharpness-Aware Minimization, SAM)的优化算法,并在CIFAR-10/100数据集上进行对比实验。\n\n## 实验环境\n\n### 硬件要求\n- AMD GPU (支持ROCm) 或 NVIDIA GPU (支持CUDA)\n- 至少8GB显存(推荐" + } + }, + { + "id": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "type": "repo", + "label": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "props": { + "language": "Python", + "stars": 0, + "forks": 0, + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "readme_head": "# 赛道二点云降噪 —— 基于 StraightPCF 的三阶段耦合速度场降噪\n\n**赛道**:赛道二:基于深度学习的三维点云降噪任务 \n**团队名**:铲屎官\n\n本仓库为「计图(Jittor)点云降噪赛题」的参赛代码。任务是给定从三维物体表面采样并加入噪声的点云,预测每个点的位移向量,将含噪点「推回」真实物体表面附近,输出与输入点数严格一致的降噪点云。最终成绩由 Chamfer Distanc" + } + }, + { + "id": "repo:shenyan/machineLearningTemplate", + "type": "repo", + "label": "shenyan/machineLearningTemplate", + "props": { + "language": "Python", + "stars": 1, + "forks": 0, + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "readme_head": "" + } + }, + { + "id": "topic:deep_learning", + "type": "topic", + "label": "deep_learning", + "props": { + "count": 11, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:scientific_computing", + "type": "topic", + "label": "scientific_computing", + "props": { + "count": 6, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:time_series", + "type": "topic", + "label": "time_series", + "props": { + "count": 3, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:computer_vision", + "type": "topic", + "label": "computer_vision", + "props": { + "count": 6, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:database", + "type": "topic", + "label": "database", + "props": { + "count": 3, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:machine_learning", + "type": "topic", + "label": "machine_learning", + "props": { + "count": 3, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:nlp", + "type": "topic", + "label": "nlp", + "props": { + "count": 2, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:devops", + "type": "topic", + "label": "devops", + "props": { + "count": 1, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:generative_ai", + "type": "topic", + "label": "generative_ai", + "props": { + "count": 2, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:reinforcement_learning", + "type": "topic", + "label": "reinforcement_learning", + "props": { + "count": 2, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:data_mining", + "type": "topic", + "label": "data_mining", + "props": { + "count": 1, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:autonomous_systems", + "type": "topic", + "label": "autonomous_systems", + "props": { + "count": 1, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "topic:graph_learning", + "type": "topic", + "label": "graph_learning", + "props": { + "count": 1, + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:dong liu", + "type": "scholar", + "label": "dong liu", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:tianqi chen", + "type": "scholar", + "label": "tianqi chen", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:eric lunderberg", + "type": "scholar", + "label": "eric lunderberg", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:masahi", + "type": "scholar", + "label": "masahi", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:ruihang lai", + "type": "scholar", + "label": "ruihang lai", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:wuwei lin", + "type": "scholar", + "label": "wuwei lin", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:siyuan feng", + "type": "scholar", + "label": "siyuan feng", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:driazati", + "type": "scholar", + "label": "driazati", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:krzysztof parzyszek", + "type": "scholar", + "label": "krzysztof parzyszek", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:mehrdad hessar", + "type": "scholar", + "label": "mehrdad hessar", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:junru shao", + "type": "scholar", + "label": "junru shao", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:zhi", + "type": "scholar", + "label": "zhi", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:matthew brookhart", + "type": "scholar", + "label": "matthew brookhart", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:tqchen", + "type": "scholar", + "label": "tqchen", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:haichen shen", + "type": "scholar", + "label": "haichen shen", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:luke hutton", + "type": "scholar", + "label": "luke hutton", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:andrew reusch", + "type": "scholar", + "label": "andrew reusch", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:ziheng", + "type": "scholar", + "label": "ziheng", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:cody yu", + "type": "scholar", + "label": "cody yu", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:jalew", + "type": "scholar", + "label": "jalew", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:wen-feng zeng", + "type": "scholar", + "label": "wen-feng zeng", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:Edge", + "type": "scholar", + "label": "Edge", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:Edgedev", + "type": "scholar", + "label": "Edgedev", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:AIEven", + "type": "scholar", + "label": "AIEven", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:aaasdasd", + "type": "scholar", + "label": "aaasdasd", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:404notfound233", + "type": "scholar", + "label": "404notfound233", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:beginner401", + "type": "scholar", + "label": "beginner401", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:gandalf401", + "type": "scholar", + "label": "gandalf401", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:cloudy1225", + "type": "scholar", + "label": "cloudy1225", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:CocytusXRS", + "type": "scholar", + "label": "CocytusXRS", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:ly15927029790", + "type": "scholar", + "label": "ly15927029790", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:Hua135", + "type": "scholar", + "label": "Hua135", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:ash", + "type": "scholar", + "label": "ash", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:ashh", + "type": "scholar", + "label": "ashh", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + }, + { + "id": "scholar:shenyan", + "type": "scholar", + "label": "shenyan", + "props": { + "language": "", + "stars": 0, + "forks": 0, + "description": "" + } + } + ], + "edges": [ + { + "source": "repo:dongeliu/dlvc", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:co63oc/mcTVM", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:co63oc/mcTVM", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:opensci/pDeep", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:opensci/pDeep", + "target": "topic:time_series", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Edgedev/Edge-Computing-Engine", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Edgedev/Edge-Computing-Engine", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Edgedev/Edge-Computing-Engine", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Edgedev/Edge-Computing-Engine", + "target": "topic:database", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:clj111/Edge-Computing-Engine", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:machine_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:nlp", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:AIEven/AIkun", + "target": "topic:devops", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:njuiselab/Gandalf", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:njuiselab/Gandalf", + "target": "topic:generative_ai", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:njuiselab/Gandalf", + "target": "topic:reinforcement_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:njuiselab/Gandalf", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:reinforcement_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:time_series", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:CocytusXRS/AI_test", + "target": "topic:data_mining", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ly15927029790/CodeRecommendation", + "target": "topic:generative_ai", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ly15927029790/CodeRecommendation", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ly15927029790/CodeRecommendation", + "target": "topic:database", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:machine_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:scientific_computing", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:autonomous_systems", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:Hua135/sam-optimizers", + "target": "topic:database", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "target": "topic:computer_vision", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "target": "topic:nlp", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "target": "topic:graph_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "target": "topic:time_series", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:shenyan/machineLearningTemplate", + "target": "topic:machine_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "repo:shenyan/machineLearningTemplate", + "target": "topic:deep_learning", + "type": "covers_topic", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:devops", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:generative_ai", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:generative_ai", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:data_mining", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:autonomous_systems", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:deep_learning", + "target": "topic:graph_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:devops", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:data_mining", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:scientific_computing", + "target": "topic:autonomous_systems", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:data_mining", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:time_series", + "target": "topic:graph_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:devops", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:generative_ai", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:data_mining", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:autonomous_systems", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:computer_vision", + "target": "topic:graph_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:generative_ai", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:database", + "target": "topic:autonomous_systems", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:devops", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:autonomous_systems", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:machine_learning", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:devops", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:graph_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:nlp", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:devops", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:devops", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:devops", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:devops", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:devops", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:generative_ai", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:generative_ai", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:generative_ai", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:generative_ai", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:generative_ai", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:generative_ai", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:reinforcement_learning", + "target": "topic:data_mining", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:data_mining", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:data_mining", + "target": "topic:reinforcement_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:data_mining", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:data_mining", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:data_mining", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:autonomous_systems", + "target": "topic:machine_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:autonomous_systems", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:autonomous_systems", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:autonomous_systems", + "target": "topic:scientific_computing", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:autonomous_systems", + "target": "topic:database", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:graph_learning", + "target": "topic:computer_vision", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:graph_learning", + "target": "topic:nlp", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:graph_learning", + "target": "topic:deep_learning", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "topic:graph_learning", + "target": "topic:time_series", + "type": "related_to", + "weight": 1.0 + }, + { + "source": "scholar:dong liu", + "target": "repo:dongeliu/dlvc", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.1078 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tianqi chen", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.046 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:eric lunderberg", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0339 + }, + { + "source": "scholar:masahi", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:masahi", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0207 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ruihang lai", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0194 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wuwei lin", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0177 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:siyuan feng", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0205 + }, + { + "source": "scholar:driazati", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:driazati", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0166 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:krzysztof parzyszek", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0148 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:mehrdad hessar", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.014 + }, + { + "source": "scholar:junru shao", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0097 + }, + { + "source": "scholar:junru shao", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:junru shao", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0103 + }, + { + "source": "scholar:zhi", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:zhi", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0097 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:matthew brookhart", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0096 + }, + { + "source": "scholar:tqchen", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:tqchen", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0092 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:haichen shen", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0089 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:luke hutton", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0083 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:andrew reusch", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0086 + }, + { + "source": "scholar:ziheng", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:cody yu", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ziheng", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "repo:co63oc/mcTVM", + "type": "contributes_to", + "weight": 0.0086 + }, + { + "source": "scholar:cody yu", + "target": "scholar:andrew reusch", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:driazati", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:eric lunderberg", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:haichen shen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:junru shao", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:krzysztof parzyszek", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:luke hutton", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:masahi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:matthew brookhart", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:mehrdad hessar", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:ruihang lai", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:siyuan feng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:tianqi chen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:tqchen", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:wuwei lin", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:zhi", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cody yu", + "target": "scholar:ziheng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:jalew", + "target": "repo:opensci/pDeep", + "type": "contributes_to", + "weight": 0.9615 + }, + { + "source": "scholar:jalew", + "target": "scholar:wen-feng zeng", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:wen-feng zeng", + "target": "repo:opensci/pDeep", + "type": "contributes_to", + "weight": 0.0385 + }, + { + "source": "scholar:wen-feng zeng", + "target": "scholar:jalew", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:Edge", + "target": "repo:Edgedev/Edge-Computing-Engine", + "type": "contributes_to", + "weight": 0.75 + }, + { + "source": "scholar:Edge", + "target": "repo:clj111/Edge-Computing-Engine", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:Edge", + "target": "scholar:Edgedev", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:Edgedev", + "target": "repo:Edgedev/Edge-Computing-Engine", + "type": "contributes_to", + "weight": 0.25 + }, + { + "source": "scholar:Edgedev", + "target": "repo:Edgedev/Edge-Computing-Engine", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:Edgedev", + "target": "scholar:Edge", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:AIEven", + "target": "repo:AIEven/AIkun", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:AIEven", + "target": "repo:AIEven/AIkun", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:aaasdasd", + "target": "repo:aaasdasd/aa", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:aaasdasd", + "target": "repo:aaasdasd/aa", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:404notfound233", + "target": "repo:njuiselab/Gandalf", + "type": "contributes_to", + "weight": 0.5 + }, + { + "source": "scholar:404notfound233", + "target": "scholar:beginner401", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:404notfound233", + "target": "scholar:gandalf401", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:beginner401", + "target": "repo:njuiselab/Gandalf", + "type": "contributes_to", + "weight": 0.4167 + }, + { + "source": "scholar:beginner401", + "target": "scholar:404notfound233", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:beginner401", + "target": "scholar:gandalf401", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:gandalf401", + "target": "repo:njuiselab/Gandalf", + "type": "contributes_to", + "weight": 0.0833 + }, + { + "source": "scholar:gandalf401", + "target": "scholar:404notfound233", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:gandalf401", + "target": "scholar:beginner401", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:cloudy1225", + "target": "repo:CocytusXRS/AI_test", + "type": "contributes_to", + "weight": 0.5 + }, + { + "source": "scholar:cloudy1225", + "target": "scholar:CocytusXRS", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:CocytusXRS", + "target": "repo:CocytusXRS/AI_test", + "type": "contributes_to", + "weight": 0.5 + }, + { + "source": "scholar:CocytusXRS", + "target": "repo:CocytusXRS/AI_test", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:CocytusXRS", + "target": "scholar:cloudy1225", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ly15927029790", + "target": "repo:ly15927029790/CodeRecommendation", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:ly15927029790", + "target": "repo:ly15927029790/CodeRecommendation", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:Hua135", + "target": "repo:Hua135/sam-optimizers", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:Hua135", + "target": "repo:Hua135/sam-optimizers", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:ash", + "target": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "type": "contributes_to", + "weight": 0.75 + }, + { + "source": "scholar:ash", + "target": "scholar:ashh", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:ashh", + "target": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "type": "contributes_to", + "weight": 0.25 + }, + { + "source": "scholar:ashh", + "target": "repo:ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "type": "owns", + "weight": 1.0 + }, + { + "source": "scholar:ashh", + "target": "scholar:ash", + "type": "collaborates_with", + "weight": 1.0 + }, + { + "source": "scholar:shenyan", + "target": "repo:shenyan/machineLearningTemplate", + "type": "contributes_to", + "weight": 1.0 + }, + { + "source": "scholar:shenyan", + "target": "repo:shenyan/machineLearningTemplate", + "type": "owns", + "weight": 1.0 + } + ], + "core_scholars": [ + { + "login": "Edge", + "repo_count": 2 + }, + { + "login": "404notfound233", + "repo_count": 1 + }, + { + "login": "AIEven", + "repo_count": 1 + }, + { + "login": "CocytusXRS", + "repo_count": 1 + }, + { + "login": "Edgedev", + "repo_count": 1 + }, + { + "login": "Hua135", + "repo_count": 1 + }, + { + "login": "aaasdasd", + "repo_count": 1 + }, + { + "login": "andrew reusch", + "repo_count": 1 + }, + { + "login": "ash", + "repo_count": 1 + }, + { + "login": "ashh", + "repo_count": 1 + }, + { + "login": "beginner401", + "repo_count": 1 + }, + { + "login": "cloudy1225", + "repo_count": 1 + }, + { + "login": "cody yu", + "repo_count": 1 + }, + { + "login": "dong liu", + "repo_count": 1 + }, + { + "login": "driazati", + "repo_count": 1 + } + ], + "core_teams": [], + "topic_heat": [ + { + "topic": "deep_learning", + "count": 6 + }, + { + "topic": "scientific_computing", + "count": 3 + }, + { + "topic": "machine_learning", + "count": 2 + }, + { + "topic": "time_series", + "count": 1 + }, + { + "topic": "generative_ai", + "count": 1 + }, + { + "topic": "autonomous_systems", + "count": 1 + }, + { + "topic": "nlp", + "count": 1 + } + ], + "meta": { + "keywords": [ + "深度学习" + ], + "repo_count": 13, + "node_count": 61, + "edge_count": 553, + "scholar_count": 35, + "topic_count": 13 + }, + "trending_repos": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 4, + "forks": 1, + "updated": "", + "velocity": 0.0, + "score": 6.0 + }, + { + "repo": "shenyan/machineLearningTemplate", + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "language": "Python", + "stars": 1, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 1.0 + }, + { + "repo": "dongeliu/dlvc", + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "language": "C++", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "co63oc/mcTVM", + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "opensci/pDeep", + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "clj111/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "AIEven/AIkun", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "aaasdasd/aa", + "description": "", + "language": "Python3.6", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "njuiselab/Gandalf", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "CocytusXRS/AI_test", + "description": "", + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "ly15927029790/CodeRecommendation", + "description": "代码生成式补全工具", + "language": "Java", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "Hua135/sam-optimizers", + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + }, + { + "repo": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "language": "Python", + "stars": 0, + "forks": 0, + "updated": "", + "velocity": 0.0, + "score": 0.0 + } + ], + "active_discussions": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "type": "issue", + "number": 2, + "title": "实现基本矩阵类和深度学习pipeline测试", + "comments": 0, + "state": "{'id': 3, 'name': '已解决', 'pm_color': '#13b33e'}" + }, + { + "repo": "Edgedev/Edge-Computing-Engine", + "type": "issue", + "number": 1, + "title": "welcome功能", + "comments": 0, + "state": "{'id': 3, 'name': '已解决', 'pm_color': '#13b33e'}" + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/knowledge-graph/graph.mmd b/research-output/session_20260707_203632/knowledge-graph/graph.mmd new file mode 100644 index 0000000..fe1c2ea --- /dev/null +++ b/research-output/session_20260707_203632/knowledge-graph/graph.mmd @@ -0,0 +1,265 @@ +```mermaid +graph TD + classDef repoNode fill:#4C78A8,stroke:#333,color:#fff; + classDef scholarNode fill:#F58518,stroke:#333,color:#fff; + classDef topicNode fill:#54A24B,stroke:#333,color:#fff; + repo_AIEven_AIkun["AIEven/AIkun"] + class repo_AIEven_AIkun repoNode; + repo_CocytusXRS_AI_test["CocytusXRS/AI_test"] + class repo_CocytusXRS_AI_test repoNode; + repo_Edgedev_Edge_Computing_Engine["Edgedev/Edge-Computing-Engine"] + class repo_Edgedev_Edge_Computing_Engine repoNode; + repo_Hua135_sam_optimizers["Hua135/sam-optimizers"] + class repo_Hua135_sam_optimizers repoNode; + repo_aaasdasd_aa["aaasdasd/aa"] + class repo_aaasdasd_aa repoNode; + repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise["ashh/jittor-chanshiguan-track2-straightpcf-denoise"] + class repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise repoNode; + repo_clj111_Edge_Computing_Engine["clj111/Edge-Computing-Engine"] + class repo_clj111_Edge_Computing_Engine repoNode; + repo_co63oc_mcTVM["co63oc/mcTVM"] + class repo_co63oc_mcTVM repoNode; + repo_dongeliu_dlvc["dongeliu/dlvc"] + class repo_dongeliu_dlvc repoNode; + repo_ly15927029790_CodeRecommendation["ly15927029790/CodeRecommendation"] + class repo_ly15927029790_CodeRecommendation repoNode; + repo_njuiselab_Gandalf["njuiselab/Gandalf"] + class repo_njuiselab_Gandalf repoNode; + repo_opensci_pDeep["opensci/pDeep"] + class repo_opensci_pDeep repoNode; + repo_shenyan_machineLearningTemplate["shenyan/machineLearningTemplate"] + class repo_shenyan_machineLearningTemplate repoNode; + scholar_404notfound233["404notfound233"] + class scholar_404notfound233 scholarNode; + scholar_AIEven["AIEven"] + class scholar_AIEven scholarNode; + scholar_CocytusXRS["CocytusXRS"] + class scholar_CocytusXRS scholarNode; + scholar_Edge["Edge"] + class scholar_Edge scholarNode; + scholar_Edgedev["Edgedev"] + class scholar_Edgedev scholarNode; + scholar_Hua135["Hua135"] + class scholar_Hua135 scholarNode; + scholar_aaasdasd["aaasdasd"] + class scholar_aaasdasd scholarNode; + scholar_andrew reusch["andrew reusch"] + class scholar_andrew reusch scholarNode; + scholar_ash["ash"] + class scholar_ash scholarNode; + scholar_ashh["ashh"] + class scholar_ashh scholarNode; + scholar_beginner401["beginner401"] + class scholar_beginner401 scholarNode; + scholar_cloudy1225["cloudy1225"] + class scholar_cloudy1225 scholarNode; + scholar_cody yu["cody yu"] + class scholar_cody yu scholarNode; + scholar_dong liu["dong liu"] + class scholar_dong liu scholarNode; + scholar_driazati["driazati"] + class scholar_driazati scholarNode; + scholar_eric lunderberg["eric lunderberg"] + class scholar_eric lunderberg scholarNode; + scholar_gandalf401["gandalf401"] + class scholar_gandalf401 scholarNode; + scholar_haichen shen["haichen shen"] + class scholar_haichen shen scholarNode; + scholar_jalew["jalew"] + class scholar_jalew scholarNode; + scholar_junru shao["junru shao"] + class scholar_junru shao scholarNode; + scholar_krzysztof parzyszek["krzysztof parzyszek"] + class scholar_krzysztof parzyszek scholarNode; + scholar_luke hutton["luke hutton"] + class scholar_luke hutton scholarNode; + scholar_ly15927029790["ly15927029790"] + class scholar_ly15927029790 scholarNode; + scholar_masahi["masahi"] + class scholar_masahi scholarNode; + scholar_matthew brookhart["matthew brookhart"] + class scholar_matthew brookhart scholarNode; + scholar_mehrdad hessar["mehrdad hessar"] + class scholar_mehrdad hessar scholarNode; + scholar_ruihang lai["ruihang lai"] + class scholar_ruihang lai scholarNode; + scholar_dong liu -- "contributes_to(1.00)" --> repo_dongeliu_dlvc + scholar_eric lunderberg -- "contributes_to(0.05)" --> repo_co63oc_mcTVM + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_driazati + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_masahi + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_eric lunderberg -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_masahi -- "contributes_to(0.03)" --> repo_co63oc_mcTVM + scholar_masahi -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_masahi -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_masahi -- "collaborates_with(1.00)" --> scholar_driazati + scholar_masahi -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_masahi -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_masahi -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_masahi -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_masahi -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_masahi -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_masahi -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_masahi -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_ruihang lai -- "contributes_to(0.02)" --> repo_co63oc_mcTVM + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_driazati + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_masahi + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_ruihang lai -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_driazati -- "contributes_to(0.02)" --> repo_co63oc_mcTVM + scholar_driazati -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_driazati -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_driazati -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_driazati -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_driazati -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_driazati -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_driazati -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_driazati -- "collaborates_with(1.00)" --> scholar_masahi + scholar_driazati -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_driazati -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_driazati -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_krzysztof parzyszek -- "contributes_to(0.02)" --> repo_co63oc_mcTVM + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_driazati + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_masahi + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_krzysztof parzyszek -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_mehrdad hessar -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_driazati + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_masahi + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_mehrdad hessar -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_junru shao -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_driazati + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_masahi + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_junru shao -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_matthew brookhart -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_driazati + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_masahi + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_matthew brookhart -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_haichen shen -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_driazati + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_masahi + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_haichen shen -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_luke hutton -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_driazati + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_masahi + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_luke hutton -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_andrew reusch -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_cody yu + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_driazati + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_masahi + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_andrew reusch -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_cody yu -- "contributes_to(0.01)" --> repo_co63oc_mcTVM + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_andrew reusch + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_driazati + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_eric lunderberg + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_haichen shen + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_junru shao + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_krzysztof parzyszek + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_luke hutton + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_masahi + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_matthew brookhart + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_mehrdad hessar + scholar_cody yu -- "collaborates_with(1.00)" --> scholar_ruihang lai + scholar_jalew -- "contributes_to(0.96)" --> repo_opensci_pDeep + scholar_Edge -- "contributes_to(0.75)" --> repo_Edgedev_Edge_Computing_Engine + scholar_Edge -- "contributes_to(1.00)" --> repo_clj111_Edge_Computing_Engine + scholar_Edge -- "collaborates_with(1.00)" --> scholar_Edgedev + scholar_Edgedev -- "contributes_to(0.25)" --> repo_Edgedev_Edge_Computing_Engine + scholar_Edgedev -- "owns(1.00)" --> repo_Edgedev_Edge_Computing_Engine + scholar_Edgedev -- "collaborates_with(1.00)" --> scholar_Edge + scholar_AIEven -- "contributes_to(1.00)" --> repo_AIEven_AIkun + scholar_AIEven -- "owns(1.00)" --> repo_AIEven_AIkun + scholar_aaasdasd -- "contributes_to(1.00)" --> repo_aaasdasd_aa + scholar_aaasdasd -- "owns(1.00)" --> repo_aaasdasd_aa + scholar_404notfound233 -- "contributes_to(0.50)" --> repo_njuiselab_Gandalf + scholar_404notfound233 -- "collaborates_with(1.00)" --> scholar_beginner401 + scholar_404notfound233 -- "collaborates_with(1.00)" --> scholar_gandalf401 + scholar_beginner401 -- "contributes_to(0.42)" --> repo_njuiselab_Gandalf + scholar_beginner401 -- "collaborates_with(1.00)" --> scholar_404notfound233 + scholar_beginner401 -- "collaborates_with(1.00)" --> scholar_gandalf401 + scholar_gandalf401 -- "contributes_to(0.08)" --> repo_njuiselab_Gandalf + scholar_gandalf401 -- "collaborates_with(1.00)" --> scholar_404notfound233 + scholar_gandalf401 -- "collaborates_with(1.00)" --> scholar_beginner401 + scholar_cloudy1225 -- "contributes_to(0.50)" --> repo_CocytusXRS_AI_test + scholar_cloudy1225 -- "collaborates_with(1.00)" --> scholar_CocytusXRS + scholar_CocytusXRS -- "contributes_to(0.50)" --> repo_CocytusXRS_AI_test + scholar_CocytusXRS -- "owns(1.00)" --> repo_CocytusXRS_AI_test + scholar_CocytusXRS -- "collaborates_with(1.00)" --> scholar_cloudy1225 + scholar_ly15927029790 -- "contributes_to(1.00)" --> repo_ly15927029790_CodeRecommendation + scholar_ly15927029790 -- "owns(1.00)" --> repo_ly15927029790_CodeRecommendation + scholar_Hua135 -- "contributes_to(1.00)" --> repo_Hua135_sam_optimizers + scholar_Hua135 -- "owns(1.00)" --> repo_Hua135_sam_optimizers + scholar_ash -- "contributes_to(0.75)" --> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise + scholar_ash -- "collaborates_with(1.00)" --> scholar_ashh + scholar_ashh -- "contributes_to(0.25)" --> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise + scholar_ashh -- "owns(1.00)" --> repo_ashh_jittor_chanshiguan_track2_straightpcf_denoise + scholar_ashh -- "collaborates_with(1.00)" --> scholar_ash +``` \ No newline at end of file diff --git a/research-output/session_20260707_203632/knowledge-graph/report.md b/research-output/session_20260707_203632/knowledge-graph/report.md new file mode 100644 index 0000000..2dac445 --- /dev/null +++ b/research-output/session_20260707_203632/knowledge-graph/report.md @@ -0,0 +1,68 @@ +# 科研热点追踪与知识图谱报告 + +> 场景 S2 · 子赛题四「应用 GitLink 辅助科研」 + +**关键词**: 深度学习 + +## 一、图谱概览 + +- 仓库节点: **13** +- 学者节点: **35** +- 主题节点: **13** +- 节点总数: **61** +- 边总数: **553** + +## 二、主题热度榜(基于全部仓库 description) + +| 排名 | 主题 | 覆盖仓库数 | +|------|------|-----------| +| 1 | `deep_learning` | 6 | +| 2 | `scientific_computing` | 3 | +| 3 | `machine_learning` | 2 | +| 4 | `time_series` | 1 | +| 5 | `generative_ai` | 1 | +| 6 | `autonomous_systems` | 1 | +| 7 | `nlp` | 1 | + +## 三、核心学者(按出现仓库数排序) + +| 排名 | 学者 | 关联仓库数 | +|------|------|-----------| +| 1 | `Edge` | 2 | +| 2 | `404notfound233` | 1 | +| 3 | `AIEven` | 1 | +| 4 | `CocytusXRS` | 1 | +| 5 | `Edgedev` | 1 | +| 6 | `Hua135` | 1 | +| 7 | `aaasdasd` | 1 | +| 8 | `andrew reusch` | 1 | +| 9 | `ash` | 1 | +| 10 | `ashh` | 1 | + +## 四、核心团队(组织型仓库拥有者) + +(该批仓库均由个人账号拥有,无组织型团队) + +## 五、热门 / 飙升项目(热度排序) + +| 仓库 | 语言 | ★ | ⑂ | 日均★ | 最近更新 | +|------|------|---:|---:|---:|----------| +| `Edgedev/Edge-Computing-Engine` | C++ | 4 | 1 | 0.0 | — | +| `shenyan/machineLearningTemplate` | Python | 1 | 0 | 0.0 | — | +| `dongeliu/dlvc` | C++ | 0 | 0 | 0.0 | — | +| `co63oc/mcTVM` | Python | 0 | 0 | 0.0 | — | +| `opensci/pDeep` | Python | 0 | 0 | 0.0 | — | +| `clj111/Edge-Computing-Engine` | C++ | 0 | 0 | 0.0 | — | +| `AIEven/AIkun` | Python | 0 | 0 | 0.0 | — | +| `aaasdasd/aa` | Python3.6 | 0 | 0 | 0.0 | — | +| `njuiselab/Gandalf` | Python | 0 | 0 | 0.0 | — | +| `CocytusXRS/AI_test` | Jupyter notebook | 0 | 0 | 0.0 | — | + +## 六、活跃讨论(评论最多的 Issue / PR) + +# | 类型 | 仓库 | 标题 | 评论 | +|-|------|------|------|---:| +| 1 | issue | `Edgedev/Edge-Computing-Engine` | 实现基本矩阵类和深度学习pipeline测试 | 0 | +| 2 | issue | `Edgedev/Edge-Computing-Engine` | welcome功能 | 0 | + +_配套产物:graph.json(结构化)+ graph.mmd(Mermaid)+ graph.dot(Graphviz DOT)_ diff --git a/research-output/session_20260707_203632/lineage/branch_graph.mmd b/research-output/session_20260707_203632/lineage/branch_graph.mmd new file mode 100644 index 0000000..b6b3377 --- /dev/null +++ b/research-output/session_20260707_203632/lineage/branch_graph.mmd @@ -0,0 +1,4 @@ +```mermaid +gitGraph + commit id: "master 起点" +``` \ No newline at end of file diff --git a/research-output/session_20260707_203632/lineage/lineage.json b/research-output/session_20260707_203632/lineage/lineage.json new file mode 100644 index 0000000..4a3455a --- /dev/null +++ b/research-output/session_20260707_203632/lineage/lineage.json @@ -0,0 +1,58 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "caoweiqiong/zwf", + "default_branch": "master", + "commit_timeline": [ + { + "date": "2026-05-09", + "count": 7 + }, + { + "date": "2026-05-10", + "count": 8 + }, + { + "date": "2026-06-11", + "count": 1 + }, + { + "date": "2026-06-23", + "count": 2 + }, + { + "date": "2026-07-03", + "count": 6 + }, + { + "date": "2026-07-04", + "count": 4 + }, + { + "date": "2026-07-06", + "count": 2 + } + ], + "branch_map": [ + { + "name": "master", + "commits": 30, + "last_active": "2026-07-06", + "is_default": true + } + ], + "pr_merge_patterns": [], + "doc_evolution": [ + { + "file": "superpowers", + "last_date": "" + } + ], + "experiment_files": [], + "innovation_points": [], + "meta": { + "commit_count": 30, + "merged_pr_count": 0, + "doc_count": 1, + "experiment_file_count": 0 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/lineage/report.md b/research-output/session_20260707_203632/lineage/report.md new file mode 100644 index 0000000..899fd47 --- /dev/null +++ b/research-output/session_20260707_203632/lineage/report.md @@ -0,0 +1,40 @@ +# 仓库级科研项目洞悉报告 — caoweiqiong/zwf + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `master` +- **采样提交**: 30 条(默认分支,最多 10×100) +- **已合并 PR**: 0 个 +- **文档文件**: 1 个 +- **实验/评测文件**: 0 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-05-09 → 2026-07-06(共 7 个有提交的日期) +- 峰值: 2026-05-10 当日 8 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `master` | 30 | 2026-07-06 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +- (无已合并 PR) + +## 五、创新/里程碑点 + +- (未识别到明显高影响合并) + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| superpowers | — | + +## 七、实验/评测文件组织 + +- (未在仓库树中识别到 experiment/benchmark/eval/test/data 目录) diff --git a/research-output/session_20260707_203632/match/match.json b/research-output/session_20260707_203632/match/match.json new file mode 100644 index 0000000..fd51636 --- /dev/null +++ b/research-output/session_20260707_203632/match/match.json @@ -0,0 +1,155 @@ +{ + "scenario": "S4_collaboration_matching", + "repo": "caoweiqiong/zwf", + "gap_topics": [ + "computer_vision", + "generative_ai", + "devops", + "database", + "security" + ], + "needed_languages": [ + "Batchfile", + "CSS", + "HTML", + "JavaScript", + "Python", + "Shell", + "TypeScript", + "python", + "r", + "typescript" + ], + "gap_signals": [], + "candidates": [ + { + "login": "dev", + "score": 17.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "LegendC", + "score": 16.2, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "typescript" + ], + "repo_count": 2, + "reasons": [ + "语言匹配: typescript", + "本仓库活跃贡献者" + ] + }, + { + "login": "huster42", + "score": 15.4, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "whale", + "score": 14.6, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "pxz2fgtcv", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pepox4cvf", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [ + "markdown" + ], + "repo_count": 1, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pfmy8cv3n", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "mcvzf42hi", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pbfzcvkfl", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + }, + { + "login": "pcvyk8fg2", + "score": 8.0, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "medium", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "无明显主题/语言重叠" + ] + } + ], + "meta": { + "pool_size": 15, + "issue_sample": 100 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/match/network.mmd b/research-output/session_20260707_203632/match/network.mmd new file mode 100644 index 0000000..394337b --- /dev/null +++ b/research-output/session_20260707_203632/match/network.mmd @@ -0,0 +1,20 @@ +```mermaid +graph TD + R["caoweiqiong/zwf
(目标仓库)"] + C1["dev
17.8分"] + R -- "0.0" --> C1 + C2["LegendC
16.2分"] + R -- "0.0" --> C2 + C3["huster42
15.4分"] + R -- "0.0" --> C3 + C4["whale
14.6分"] + R -- "0.0" --> C4 + C5["pxz2fgtcv
8.0分"] + R -- "0.0" --> C5 + C6["pepox4cvf
8.0分"] + R -- "0.0" --> C6 + C7["pfmy8cv3n
8.0分"] + R -- "0.0" --> C7 + C8["mcvzf42hi
8.0分"] + R -- "0.0" --> C8 +``` \ No newline at end of file diff --git a/research-output/session_20260707_203632/match/report.md b/research-output/session_20260707_203632/match/report.md new file mode 100644 index 0000000..cb5b587 --- /dev/null +++ b/research-output/session_20260707_203632/match/report.md @@ -0,0 +1,29 @@ +# 科研协作智能匹配报告 — caoweiqiong/zwf + +> 场景 S4 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、仓库技术缺口分析 + +- **缺口主题**: computer_vision, generative_ai, devops, database, security +- **需求语言**: Batchfile, CSS, HTML, JavaScript, Python, Shell, TypeScript, python, r, typescript +- **缺口信号样本**: 0 条未解决 Issue/PR 主题证据 + +| 缺口主题 | 证据(Issue/PR) | 优先级 | +|----------|------------------|--------| + +## 二、推荐协作伙伴(按综合匹配分排序) + +| 排名 | 用户 | 匹配分 | 主题重叠 | 语言匹配 | 活跃度 | 匹配理由 | +|------|------|--------|----------|----------|--------|----------| +| 1 | `dev` | 17.8 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| 2 | `LegendC` | 16.2 | 0.0 | 0.1 | high | 语言匹配: typescript; 本仓库活跃贡献者 | +| 3 | `huster42` | 15.4 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| 4 | `whale` | 14.6 | 0.0 | 0.0 | high | 本仓库活跃贡献者 | +| 5 | `pxz2fgtcv` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | +| 6 | `pepox4cvf` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | +| 7 | `pfmy8cv3n` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | +| 8 | `mcvzf42hi` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | +| 9 | `pbfzcvkfl` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | +| 10 | `pcvyk8fg2` | 8.0 | 0.0 | 0.0 | medium | 无明显主题/语言重叠 | + +_候选池规模 15,issue 采样 100_ diff --git a/research-output/session_20260707_203632/profile/profile.json b/research-output/session_20260707_203632/profile/profile.json new file mode 100644 index 0000000..ae54aba --- /dev/null +++ b/research-output/session_20260707_203632/profile/profile.json @@ -0,0 +1,60 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "caoweiqiong/zwf", + "name": "ZhiWenCraft", + "description": "", + "topics": [ + { + "topic": "computer_vision", + "count": 1 + }, + { + "topic": "generative_ai", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + }, + { + "topic": "database", + "count": 1 + }, + { + "topic": "security", + "count": 1 + } + ], + "languages": [ + "Batchfile", + "CSS", + "HTML", + "JavaScript", + "Python", + "Shell", + "TypeScript" + ], + "stars": 2, + "forks": 0, + "visits": 0, + "contributors_count": 4, + "top_contributors": [ + "dev", + "huster42", + "whale", + "LegendC" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 4, + "impact": 4 + }, + "score_total": 13 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/profile/report.md b/research-output/session_20260707_203632/profile/report.md new file mode 100644 index 0000000..73a352d --- /dev/null +++ b/research-output/session_20260707_203632/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 ZhiWenCraft (`caoweiqiong/zwf`) + +- ★2 ⑂0 👁0 · 贡献者 4 · 研究维度评分 **13/40** +- 主题:`computer_vision`、`generative_ai`、`devops`、`database`、`security` +- 核心贡献者:`dev`、`huster42`、`whale`、`LegendC` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260707_203632/report/report.json b/research-output/session_20260707_203632/report/report.json new file mode 100644 index 0000000..900ad40 --- /dev/null +++ b/research-output/session_20260707_203632/report/report.json @@ -0,0 +1,56 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "caoweiqiong/zwf", + "generated_at": "2026-07-07T12:39:04.575941+00:00", + "week_stats": { + "this_week": { + "commits": 12, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 0, + "prs_merged": 0, + "prs_open_stale": 0, + "contributors_active": 4, + "contributors_active_logins": [ + "Dev", + "HUSTER42", + "LegendC", + "whale" + ] + }, + "last_week": { + "commits": 0, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 0, + "prs_merged": 0, + "prs_open_stale": 0, + "contributors_active": 0, + "contributors_active_logins": [] + }, + "window": { + "this_week_start": "2026-06-30T12:39:04.575941+00:00", + "now": "2026-07-07T12:39:04.575941+00:00", + "last_week_start": "2026-06-23T12:39:04.575941+00:00", + "last_week_end": "2026-06-30T12:39:04.575941+00:00" + }, + "total_contributors": 4 + }, + "trend": { + "commit_delta_pct": 100.0, + "activity_level": "increasing", + "this_week_commits": 12, + "last_week_commits": 0 + }, + "milestones": [], + "risk_warnings": [], + "meta": { + "commits_fetched": 30, + "issues_fetched": 0, + "prs_fetched": 0, + "milestones_fetched": 0, + "contributors_fetched": 4 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/report/weekly_report.md b/research-output/session_20260707_203632/report/weekly_report.md new file mode 100644 index 0000000..eb0c361 --- /dev/null +++ b/research-output/session_20260707_203632/report/weekly_report.md @@ -0,0 +1,32 @@ +# 科研进度智能跟踪周报 — caoweiqiong/zwf + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-07T12:39:04.575941+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 12 | 0 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 0 | 0 | +| PR 合并 | 0 | 0 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 4 | 0 | + +- **趋势**:commit 周环比 **100.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +_(仓库无里程碑数据)_ + +## 三、风险预警 + +_(未触发风险阈值,进度正常)_ + +## 四、附 + +- 取数:commits=30 issues=0 prs=0 milestones=0 contributors=4 +- 窗口:本周 [2026-06-30T12:39:04.575941+00:00, 2026-07-07T12:39:04.575941+00:00];上周 [2026-06-23T12:39:04.575941+00:00, 2026-06-30T12:39:04.575941+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260707_203632/repro/compliance_report.md b/research-output/session_20260707_203632/repro/compliance_report.md new file mode 100644 index 0000000..6acfe43 --- /dev/null +++ b/research-output/session_20260707_203632/repro/compliance_report.md @@ -0,0 +1,49 @@ +# 科研项目合规与复现性检查报告 — caoweiqiong/zwf + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `None` +- **复现性评分**: **7.0/10**(及格) +- **合规性评分**: **6.2/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | FAIL | 0/2 | 未找到 .gitea/.github/.gitlab 等 CI 配置 | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: package-lock.json | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 11 个: install, 环境, 依赖, build, 构建 | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: docker-compose.yml | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | FAIL | 0/2 | 缺少 LICENSE 文件 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | FAIL | 1/2 | 未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释) | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | PASS | 2/2 | .gitignore 已配置忽略 .env | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| medium | repro/compliance | CI 配置 | | 未找到 .gitea/.github/.gitlab 等 CI 配置 | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | LICENSE 文件 | | 缺少 LICENSE 文件 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 版权声明 | | 未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释) | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | + +_复现分 7.0/10 · 合规分 6.2/10 · 树节点 22_ diff --git a/research-output/session_20260707_203632/repro/repro.json b/research-output/session_20260707_203632/repro/repro.json new file mode 100644 index 0000000..fe8c45d --- /dev/null +++ b/research-output/session_20260707_203632/repro/repro.json @@ -0,0 +1,148 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "caoweiqiong/zwf", + "default_branch": "master", + "license": "None", + "repro_items": [ + { + "name": "CI 配置", + "pass": false, + "score": 0, + "evidence": "未找到 .gitea/.github/.gitlab 等 CI 配置" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: package-lock.json" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 11 个: install, 环境, 依赖, build, 构建" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: docker-compose.yml" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": false, + "score": 0, + "evidence": "缺少 LICENSE 文件" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": false, + "score": 1, + "evidence": "未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释)" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": true, + "score": 2, + "evidence": ".gitignore 已配置忽略 .env" + } + ], + "secrets": [], + "risks": [ + { + "area": "repro/compliance", + "name": "CI 配置", + "evidence": "未找到 .gitea/.github/.gitlab 等 CI 配置", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "LICENSE 文件", + "evidence": "缺少 LICENSE 文件", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "版权声明", + "evidence": "未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释)", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + } + ], + "repro_score": 7.0, + "compliance_score": 6.2, + "meta": { + "key_files_found": [ + ".gitignore", + "README.md", + "package.json" + ], + "tree_size": 22, + "languages": { + "Batchfile": "0.2%", + "CSS": "3.4%", + "HTML": "29.2%", + "JavaScript": "1.6%", + "Python": "21.1%", + "Shell": "0.7%", + "TypeScript": "43.8%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260707_203632/visual/report.md b/research-output/session_20260707_203632/visual/report.md new file mode 100644 index 0000000..3b605d0 --- /dev/null +++ b/research-output/session_20260707_203632/visual/report.md @@ -0,0 +1,47 @@ +# 科研成果可视化沉淀报告 — caoweiqiong/zwf + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **30**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **0** +- 贡献者: **4**,里程碑: **0** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 0 | +| 2026-W23 | 0 | 0 | 0 | +| 2026-W24 | 1 | 0 | 0 | +| 2026-W25 | 0 | 0 | 0 | +| 2026-W26 | 2 | 0 | 0 | +| 2026-W27 | 10 | 0 | 0 | +| 2026-W28 | 2 | 0 | 0 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `dev` | 0 | +| `huster42` | 0 | +| `whale` | 3 | +| `LegendC` | 1 | +| `HUSTER42` | 5 | +| `Dev` | 19 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260707_203632/visual/visual.html b/research-output/session_20260707_203632/visual/visual.html new file mode 100644 index 0000000..1590885 --- /dev/null +++ b/research-output/session_20260707_203632/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260707_203632/visual/visual.json b/research-output/session_20260707_203632/visual/visual.json new file mode 100644 index 0000000..0091f9c --- /dev/null +++ b/research-output/session_20260707_203632/visual/visual.json @@ -0,0 +1,352 @@ +{ + "scenario": "S6_research_visualization", + "repo": "caoweiqiong/zwf", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 0, + 0, + 0, + 1, + 0, + 2, + 10, + 2 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "heatmap": { + "users": [ + "dev", + "huster42", + "whale", + "LegendC", + "HUSTER42", + "Dev" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 0, + 0, + 0, + 1, + 0, + 2, + 1 + ] + ] + }, + "languages": { + "Batchfile": "0.2%", + "CSS": "3.4%", + "HTML": "29.2%", + "JavaScript": "1.6%", + "Python": "21.1%", + "Shell": "0.7%", + "TypeScript": "43.8%" + }, + "milestones": [], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 30, + "issue_count": 0, + "pr_count": 0, + "milestone_count": 0, + "contributor_count": 4 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_205337/lineage/branch_graph.mmd b/research-output/session_20260707_205337/lineage/branch_graph.mmd new file mode 100644 index 0000000..4e8eff1 --- /dev/null +++ b/research-output/session_20260707_205337/lineage/branch_graph.mmd @@ -0,0 +1,34 @@ +```mermaid +gitGraph + commit id: "master 起点" + commit + commit + commit + commit id: "#5" tag: "2026-05-29" + commit + commit + commit + commit id: "#12 创新点" tag: "2026-06-01" + commit + commit + commit + commit id: "#14 创新点" tag: "2026-06-03" + commit + commit + commit + commit id: "#18 创新点" tag: "2026-06-15" + commit + commit + commit + commit id: "#21" tag: "2026-06-29" + commit + commit + commit + commit id: "#31 创新点" tag: "2026-07-07" + commit + commit + commit + commit id: "#28" tag: "2026-07-07" + commit + commit +``` \ No newline at end of file diff --git a/research-output/session_20260707_205337/lineage/lineage.json b/research-output/session_20260707_205337/lineage/lineage.json new file mode 100644 index 0000000..d7156d7 --- /dev/null +++ b/research-output/session_20260707_205337/lineage/lineage.json @@ -0,0 +1,281 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "commit_timeline": [ + { + "date": "2026-06-23", + "count": 2 + }, + { + "date": "2026-06-24", + "count": 2 + }, + { + "date": "2026-06-29", + "count": 2 + }, + { + "date": "2026-07-02", + "count": 5 + }, + { + "date": "2026-07-04", + "count": 1 + }, + { + "date": "2026-07-05", + "count": 7 + }, + { + "date": "2026-07-06", + "count": 8 + }, + { + "date": "2026-07-07", + "count": 23 + } + ], + "branch_map": [ + { + "name": "master", + "commits": 50, + "last_active": "2026-07-07", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "i18n.md", + "last_date": "" + }, + { + "file": "pr-draft.md", + "last_date": "" + }, + { + "file": "workflow-agent-design.md", + "last_date": "" + }, + { + "file": "workflow-agent-test-report.md", + "last_date": "" + } + ], + "experiment_files": [ + "docs/workflow-agent-test-report.md", + "main_test.go", + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 50, + "merged_pr_count": 23, + "doc_count": 4, + "experiment_file_count": 3 + } +} \ No newline at end of file diff --git a/research-output/session_20260707_205337/lineage/report.md b/research-output/session_20260707_205337/lineage/report.md new file mode 100644 index 0000000..4affb76 --- /dev/null +++ b/research-output/session_20260707_205337/lineage/report.md @@ -0,0 +1,71 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `master` +- **采样提交**: 50 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 4 个 +- **实验/评测文件**: 3 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-06-23 → 2026-07-07(共 8 个有提交的日期) +- 峰值: 2026-07-07 当日 23 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `master` | 50 | 2026-07-07 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| i18n.md | — | +| pr-draft.md | — | +| workflow-agent-design.md | — | +| workflow-agent-test-report.md | — | + +## 七、实验/评测文件组织 + +- `docs/workflow-agent-test-report.md` +- `main_test.go` +- `pr-test-file.txt` diff --git a/research-output/session_20260708_080955/hotspot/hotspot.json b/research-output/session_20260708_080955/hotspot/hotspot.json new file mode 100644 index 0000000..b3c0861 --- /dev/null +++ b/research-output/session_20260708_080955/hotspot/hotspot.json @@ -0,0 +1,389 @@ +{ + "scenario": "hotspot", + "keywords": [ + "深度学习" + ], + "category": "", + "trending_repos": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 3, + "forks": 1, + "visits": 593, + "score": 64, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-07-13", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "CocytusXRS/AI_test", + "description": "", + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "visits": 550, + "score": 55, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2022-12-02", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "AIEven/AIkun", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 382, + "score": 38, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2025-08-09", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "shenyan/machineLearningTemplate", + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "language": "Python", + "stars": 1, + "forks": 0, + "visits": 309, + "score": 31, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-10-22", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "opensci/pDeep", + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 292, + "score": 29, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "dongeliu/dlvc", + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 276, + "score": 27, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "Hua135/sam-optimizers", + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失值和损失锐度,有效提升了模型的泛化能力,但其计算开销约为传统优化器的两倍。本文系统研究了SAM及其两种高效变体——ESA", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 64, + "score": 26, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-23", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 41, + "score": 24, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-27", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "clj111/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 186, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-06-16", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "njuiselab/Gandalf", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 189, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2024-10-11", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ly15927029790/CodeRecommendation", + "description": "代码生成式补全工具", + "language": "Java", + "stars": 0, + "forks": 0, + "visits": 177, + "score": 17, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2020-10-12", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "co63oc/mcTVM", + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦GPU的软件生态,为开发者提供便捷、高效的深度学习模型部署解决方案,适用于人工智能、异构计算等相关领域的研发与应用场景。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 66, + "score": 16, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-04-15", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "aaasdasd/aa", + "description": "", + "language": "Python3.6", + "stars": 0, + "forks": 0, + "visits": 146, + "score": 14, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2023-07-15", + "contributors_count": 0, + "releases_count": 0 + } + ], + "active_discussions": [], + "topic_heat": [ + { + "topic": "deep_learning", + "count": 15 + }, + { + "topic": "scientific_computing", + "count": 7 + }, + { + "topic": "computer_vision", + "count": 6 + }, + { + "topic": "machine_learning", + "count": 4 + }, + { + "topic": "database", + "count": 3 + }, + { + "topic": "time_series", + "count": 3 + }, + { + "topic": "generative_ai", + "count": 3 + }, + { + "topic": "reinforcement_learning", + "count": 2 + }, + { + "topic": "nlp", + "count": 2 + }, + { + "topic": "autonomous_systems", + "count": 2 + } + ], + "core_scholars": [ + { + "login": "Edge", + "repo_count": 2, + "repos": [ + "Edgedev/Edge-Computing-Engine", + "clj111/Edge-Computing-Engine" + ] + }, + { + "login": "junru shao", + "repo_count": 2, + "repos": [ + "co63oc/mcTVM" + ] + }, + { + "login": "Edgedev", + "repo_count": 1, + "repos": [ + "Edgedev/Edge-Computing-Engine" + ] + }, + { + "login": "cloudy1225", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "CocytusXRS", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "AIEven", + "repo_count": 1, + "repos": [ + "AIEven/AIkun" + ] + }, + { + "login": "shenyan", + "repo_count": 1, + "repos": [ + "shenyan/machineLearningTemplate" + ] + }, + { + "login": "jalew", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "wen-feng zeng", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "dong liu", + "repo_count": 1, + "repos": [ + "dongeliu/dlvc" + ] + }, + { + "login": "404notfound233", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + }, + { + "login": "beginner401", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + } + ], + "core_teams": [ + { + "login": "Edgedev", + "repo_count": 1 + }, + { + "login": "CocytusXRS", + "repo_count": 1 + }, + { + "login": "AIEven", + "repo_count": 1 + }, + { + "login": "shenyan", + "repo_count": 1 + }, + { + "login": "opensci", + "repo_count": 1 + }, + { + "login": "dongeliu", + "repo_count": 1 + }, + { + "login": "clj111", + "repo_count": 1 + }, + { + "login": "njuiselab", + "repo_count": 1 + } + ], + "meta": { + "repo_count": 13, + "issue_count": 2, + "pr_count": 0, + "scholar_count": 35, + "discussion_count": 0, + "topic_count": 10 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/hotspot/report.md b/research-output/session_20260708_080955/hotspot/report.md new file mode 100644 index 0000000..d43fbbf --- /dev/null +++ b/research-output/session_20260708_080955/hotspot/report.md @@ -0,0 +1,50 @@ +# 🔬 科研热点追踪报告 + +> 关键词:深度学习 +> 扫描时间:2026-07-08 +> 覆盖仓库:13 个 · 讨论 0 条 · 主题 10 个 · 学者 35 位 + +## 🔥 飙升项目 Top 5 + +| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 | +|---|------|------|---------|--------|--------|------|------| +| 1 | `Edgedev/Edge-Computing-Engine` | C++ | 593 | 3 | 1 | 64 | 2021-07-13 | +| 2 | `CocytusXRS/AI_test` | Jupyter notebook | 550 | 0 | 0 | 55 | 2022-12-02 | +| 3 | `AIEven/AIkun` | Python | 382 | 0 | 0 | 38 | 2025-08-09 | +| 4 | `shenyan/machineLearningTemplate` | Python | 309 | 1 | 0 | 31 | 2021-10-22 | +| 5 | `opensci/pDeep` | Python | 292 | 0 | 0 | 29 | 2021-03-19 | + +## 📊 热门主题 + +- **deep_learning** — 15 个仓库 ███████████████ +- **scientific_computing** — 7 个仓库 ███████ +- **computer_vision** — 6 个仓库 ██████ +- **machine_learning** — 4 个仓库 ████ +- **database** — 3 个仓库 ███ +- **time_series** — 3 个仓库 ███ +- **generative_ai** — 3 个仓库 ███ +- **reinforcement_learning** — 2 个仓库 ██ +- **nlp** — 2 个仓库 ██ +- **autonomous_systems** — 2 个仓库 ██ + +## 👥 核心学者 + +- **Edge** — 关联 2 个仓库 +- **junru shao** — 关联 2 个仓库 +- **Edgedev** — 关联 1 个仓库 +- **cloudy1225** — 关联 1 个仓库 +- **CocytusXRS** — 关联 1 个仓库 + +## 🏛 活跃组织/团队 + +- **Edgedev** — 1 个仓库 +- **CocytusXRS** — 1 个仓库 +- **AIEven** — 1 个仓库 +- **shenyan** — 1 个仓库 +- **opensci** — 1 个仓库 +- **dongeliu** — 1 个仓库 +- **clj111** — 1 个仓库 +- **njuiselab** — 1 个仓库 + +--- +*由 gitlink-research-hotspot 自动生成* \ No newline at end of file diff --git a/research-output/session_20260708_080955/inspire/inspire.json b/research-output/session_20260708_080955/inspire/inspire.json new file mode 100644 index 0000000..9f5fed3 --- /dev/null +++ b/research-output/session_20260708_080955/inspire/inspire.json @@ -0,0 +1,184 @@ +{ + "scenario": "inspire", + "mode": "repo", + "repo": "whale_hihihi/gitlink-cli", + "gap_topics": [ + "reinforcement_learning", + "devops" + ], + "needed_languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell", + "go" + ], + "gap_signals": [], + "candidates": [ + { + "login": "wbtiger", + "score": 47.9, + "topic_overlap": 0.707, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [ + "java", + "shell" + ], + "repo_count": 15, + "reasons": [ + "覆盖缺口主题: devops", + "本仓库活跃贡献者" + ] + }, + { + "login": "Mengz", + "score": 30.9, + "topic_overlap": 0.0, + "language_match": 0.091, + "activity_level": "high", + "repo_languages": [ + "c++", + "go", + "markdown", + "python" + ], + "repo_count": 15, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=5)" + ] + }, + { + "login": "muel", + "score": 28.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 5, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=4)" + ] + }, + { + "login": "puygob236", + "score": 22.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "baoerjun", + "score": 22.3, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript" + ], + "repo_count": 4, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "Surponess", + "score": 19.6, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript", + "markdown" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=1)" + ] + }, + { + "login": "whale", + "score": 14.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "wauxing", + "score": 14.7, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + } + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + } + ], + "idea": null, + "llm_used": false +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/inspire/report.md b/research-output/session_20260708_080955/inspire/report.md new file mode 100644 index 0000000..a636f47 --- /dev/null +++ b/research-output/session_20260708_080955/inspire/report.md @@ -0,0 +1,26 @@ +# 💡 创新启发报告 + +> 焦点仓库:`whale_hihihi/gitlink-cli` +> LLM 建议:⏭ 未启用(无 DEEPSEEK_API_KEY) + +## 🎯 缺口主题 +`reinforcement_learning`, `devops` + +## 🤝 可合作学者 Top 5 +| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 | +|---|---|---|---|---|---| +| `wbtiger` | 47.9 | 0.707 | 0.0 | high | 覆盖缺口主题: devops;本仓库活跃贡献者 | +| `Mengz` | 30.9 | 0.0 | 0.091 | high | 语言匹配: go;本仓库活跃贡献者 | +| `muel` | 28.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `puygob236` | 22.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `baoerjun` | 22.3 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | + +## 🌟 近期创新点 +- feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 _(大规模重构/新特性)_ +- 修改知识图谱构建方法 _(大规模重构/新特性)_ +- feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 _(大规模重构/新特性)_ +- 新增skills _(大规模重构/新特性)_ +- 新增demo _(大规模重构/新特性)_ + +--- +*由 gitlink-research-inspire 生成* \ No newline at end of file diff --git a/research-output/session_20260708_080955/lineage/branch_graph.mmd b/research-output/session_20260708_080955/lineage/branch_graph.mmd new file mode 100644 index 0000000..4e8eff1 --- /dev/null +++ b/research-output/session_20260708_080955/lineage/branch_graph.mmd @@ -0,0 +1,34 @@ +```mermaid +gitGraph + commit id: "master 起点" + commit + commit + commit + commit id: "#5" tag: "2026-05-29" + commit + commit + commit + commit id: "#12 创新点" tag: "2026-06-01" + commit + commit + commit + commit id: "#14 创新点" tag: "2026-06-03" + commit + commit + commit + commit id: "#18 创新点" tag: "2026-06-15" + commit + commit + commit + commit id: "#21" tag: "2026-06-29" + commit + commit + commit + commit id: "#31 创新点" tag: "2026-07-07" + commit + commit + commit + commit id: "#28" tag: "2026-07-07" + commit + commit +``` \ No newline at end of file diff --git a/research-output/session_20260708_080955/lineage/lineage.json b/research-output/session_20260708_080955/lineage/lineage.json new file mode 100644 index 0000000..d7156d7 --- /dev/null +++ b/research-output/session_20260708_080955/lineage/lineage.json @@ -0,0 +1,281 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "commit_timeline": [ + { + "date": "2026-06-23", + "count": 2 + }, + { + "date": "2026-06-24", + "count": 2 + }, + { + "date": "2026-06-29", + "count": 2 + }, + { + "date": "2026-07-02", + "count": 5 + }, + { + "date": "2026-07-04", + "count": 1 + }, + { + "date": "2026-07-05", + "count": 7 + }, + { + "date": "2026-07-06", + "count": 8 + }, + { + "date": "2026-07-07", + "count": 23 + } + ], + "branch_map": [ + { + "name": "master", + "commits": 50, + "last_active": "2026-07-07", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "i18n.md", + "last_date": "" + }, + { + "file": "pr-draft.md", + "last_date": "" + }, + { + "file": "workflow-agent-design.md", + "last_date": "" + }, + { + "file": "workflow-agent-test-report.md", + "last_date": "" + } + ], + "experiment_files": [ + "docs/workflow-agent-test-report.md", + "main_test.go", + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 50, + "merged_pr_count": 23, + "doc_count": 4, + "experiment_file_count": 3 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/lineage/report.md b/research-output/session_20260708_080955/lineage/report.md new file mode 100644 index 0000000..4affb76 --- /dev/null +++ b/research-output/session_20260708_080955/lineage/report.md @@ -0,0 +1,71 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `master` +- **采样提交**: 50 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 4 个 +- **实验/评测文件**: 3 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-06-23 → 2026-07-07(共 8 个有提交的日期) +- 峰值: 2026-07-07 当日 23 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `master` | 50 | 2026-07-07 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| i18n.md | — | +| pr-draft.md | — | +| workflow-agent-design.md | — | +| workflow-agent-test-report.md | — | + +## 七、实验/评测文件组织 + +- `docs/workflow-agent-test-report.md` +- `main_test.go` +- `pr-test-file.txt` diff --git a/research-output/session_20260708_080955/profile/profile.json b/research-output/session_20260708_080955/profile/profile.json new file mode 100644 index 0000000..b9389b9 --- /dev/null +++ b/research-output/session_20260708_080955/profile/profile.json @@ -0,0 +1,52 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "whale_hihihi/gitlink-cli", + "name": "gitlink-cli", + "description": "", + "topics": [ + { + "topic": "reinforcement_learning", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + } + ], + "languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell" + ], + "stars": 0, + "forks": 0, + "visits": 0, + "contributors_count": 20, + "top_contributors": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 10, + "impact": 0 + }, + "score_total": 15 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/profile/report.md b/research-output/session_20260708_080955/profile/report.md new file mode 100644 index 0000000..903754b --- /dev/null +++ b/research-output/session_20260708_080955/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 gitlink-cli (`whale_hihihi/gitlink-cli`) + +- ★0 ⑂0 👁0 · 贡献者 20 · 研究维度评分 **15/40** +- 主题:`reinforcement_learning`、`devops` +- 核心贡献者:`wbtiger`、`whale`、`wauxing`、`Surponess`、`wangyue789`、`tiger` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260708_080955/report/report.json b/research-output/session_20260708_080955/report/report.json new file mode 100644 index 0000000..f48beed --- /dev/null +++ b/research-output/session_20260708_080955/report/report.json @@ -0,0 +1,80 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "whale_hihihi/gitlink-cli", + "generated_at": "2026-07-08T00:12:31.323273+00:00", + "week_stats": { + "this_week": { + "commits": 44, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 9, + "prs_merged": 8, + "prs_open_stale": 0, + "contributors_active": 7, + "contributors_active_logins": [ + "Surponess", + "baoerjun", + "wauxing", + "wbtiger", + "whale", + "whale_hihihi", + "yangsai01" + ] + }, + "last_week": { + "commits": 4, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 2, + "prs_merged": 2, + "prs_open_stale": 0, + "contributors_active": 1, + "contributors_active_logins": [ + "Surponess" + ] + }, + "window": { + "this_week_start": "2026-07-01T00:12:31.323273+00:00", + "now": "2026-07-08T00:12:31.323273+00:00", + "last_week_start": "2026-06-24T00:12:31.323273+00:00", + "last_week_end": "2026-07-01T00:12:31.323273+00:00" + }, + "total_contributors": 20 + }, + "trend": { + "commit_delta_pct": 1000.0, + "activity_level": "increasing", + "this_week_commits": 44, + "last_week_commits": 4 + }, + "milestones": [ + { + "name": "完成批量成员邀请", + "open": 0, + "closed": 0, + "total": 0, + "completion_pct": 0.0, + "due_date": "2026-06-04T00:00:00+00:00", + "overdue": true, + "status": "open" + } + ], + "risk_warnings": [ + { + "level": "critical", + "type": "overdue_milestone", + "message": "里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0%", + "metric": 0.0, + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。" + } + ], + "meta": { + "commits_fetched": 50, + "issues_fetched": 0, + "prs_fetched": 30, + "milestones_fetched": 1, + "contributors_fetched": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/report/weekly_report.md b/research-output/session_20260708_080955/report/weekly_report.md new file mode 100644 index 0000000..1e80720 --- /dev/null +++ b/research-output/session_20260708_080955/report/weekly_report.md @@ -0,0 +1,36 @@ +# 科研进度智能跟踪周报 — whale_hihihi/gitlink-cli + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-08T00:12:31.323273+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 44 | 4 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 9 | 2 | +| PR 合并 | 8 | 2 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 7 | 1 | + +- **趋势**:commit 周环比 **1000.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 | +|--------|-----------|--------|----------|------| +| 完成批量成员邀请 ⚠️逾期 | 0/0 | 0.0% | 2026-06-04T00:00:00+00:00 | open | + +## 三、风险预警 + +| 级别 | 类型 | 说明 | 建议 | +|------|------|------|------| +| critical | overdue_milestone | 里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0% | 重新评估范围或顺延 deadline,并同步干系人。 | + +## 四、附 + +- 取数:commits=50 issues=0 prs=30 milestones=1 contributors=20 +- 窗口:本周 [2026-07-01T00:12:31.323273+00:00, 2026-07-08T00:12:31.323273+00:00];上周 [2026-06-24T00:12:31.323273+00:00, 2026-07-01T00:12:31.323273+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260708_080955/repro/compliance_report.md b/research-output/session_20260708_080955/repro/compliance_report.md new file mode 100644 index 0000000..f48a199 --- /dev/null +++ b/research-output/session_20260708_080955/repro/compliance_report.md @@ -0,0 +1,48 @@ +# 科研项目合规与复现性检查报告 — whale_hihihi/gitlink-cli + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `MulanPSL-2.0` +- **复现性评分**: **9.0/10**(良好) +- **合规性评分**: **7.5/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | PASS | 2/2 | 检测到 CI 配置: .devops, .gitea, .github | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: go.sum | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 10 个: install, setup, build, 运行, run | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: Dockerfile | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | PASS | 2/2 | LICENSE 声明为 MulanPSL-2.0 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | PASS | 2/2 | LICENSE/README 中含 copyright/版权 声明 | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | FAIL | 1/2 | .gitignore 未忽略 .env(建议添加 .env) | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| high | privacy | 数据隐私 | | .gitignore 未忽略 .env(预防性建议) | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | +| medium | repro/compliance | .gitignore 忽略 .env | | .gitignore 未忽略 .env(建议添加 .env) | + +_复现分 9.0/10 · 合规分 7.5/10 · 树节点 31_ diff --git a/research-output/session_20260708_080955/repro/repro.json b/research-output/session_20260708_080955/repro/repro.json new file mode 100644 index 0000000..03ef7b8 --- /dev/null +++ b/research-output/session_20260708_080955/repro/repro.json @@ -0,0 +1,143 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "license": "MulanPSL-2.0", + "repro_items": [ + { + "name": "CI 配置", + "pass": true, + "score": 2, + "evidence": "检测到 CI 配置: .devops, .gitea, .github" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: go.sum" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 10 个: install, setup, build, 运行, run" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: Dockerfile" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": true, + "score": 2, + "evidence": "LICENSE 声明为 MulanPSL-2.0" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": true, + "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": false, + "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)" + } + ], + "secrets": [], + "risks": [ + { + "area": "privacy", + "name": "数据隐私", + "evidence": ".gitignore 未忽略 .env(预防性建议)", + "level": "high" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": ".gitignore 忽略 .env", + "evidence": ".gitignore 未忽略 .env(建议添加 .env)", + "level": "medium" + } + ], + "repro_score": 9.0, + "compliance_score": 7.5, + "meta": { + "key_files_found": [ + ".gitignore", + "LICENSE", + "README.md", + "go.mod" + ], + "tree_size": 31, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260708_080955/visual/report.md b/research-output/session_20260708_080955/visual/report.md new file mode 100644 index 0000000..245a626 --- /dev/null +++ b/research-output/session_20260708_080955/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **50**(窗口内峰值 31 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 4 | 0 | 2 | +| 2026-W27 | 15 | 0 | 2 | +| 2026-W28 | 31 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 1 | +| `whale` | 9 | +| `wauxing` | 0 | +| `Surponess` | 8 | +| `wangyue789` | 0 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 0 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_080955/visual/visual.html b/research-output/session_20260708_080955/visual/visual.html new file mode 100644 index 0000000..7431020 --- /dev/null +++ b/research-output/session_20260708_080955/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260708_080955/visual/visual.json b/research-output/session_20260708_080955/visual/visual.json new file mode 100644 index 0000000..2894ca0 --- /dev/null +++ b/research-output/session_20260708_080955/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 15, + 31 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 4 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 50, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_082633/hotspot/hotspot.json b/research-output/session_20260708_082633/hotspot/hotspot.json new file mode 100644 index 0000000..b3c0861 --- /dev/null +++ b/research-output/session_20260708_082633/hotspot/hotspot.json @@ -0,0 +1,389 @@ +{ + "scenario": "hotspot", + "keywords": [ + "深度学习" + ], + "category": "", + "trending_repos": [ + { + "repo": "Edgedev/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 3, + "forks": 1, + "visits": 593, + "score": 64, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-07-13", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "CocytusXRS/AI_test", + "description": "", + "language": "Jupyter notebook", + "stars": 0, + "forks": 0, + "visits": 550, + "score": 55, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2022-12-02", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "AIEven/AIkun", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 382, + "score": 38, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2025-08-09", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "shenyan/machineLearningTemplate", + "description": "基于pytorch lightning的机器学习模板, 用于对机器学习算法进行训练, 验证, 测试等, 目前实现了神经网路, 深度学习, k折交叉, 自动保存训练信息等. ", + "language": "Python", + "stars": 1, + "forks": 0, + "visits": 309, + "score": 31, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-10-22", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "opensci/pDeep", + "description": "pDeep是一种基于深度学习的质谱预测系统。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 292, + "score": 29, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "dongeliu/dlvc", + "description": "一种视频编解码原型系统,内嵌深度学习编码工具显著提高压缩效率。", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 276, + "score": 27, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-03-19", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "Hua135/sam-optimizers", + "description": "深度神经网络的泛化能力是机器学习领域的核心问题之一。传统优化算法如随机梯度下降(SGD)和Adam仅最小化训练损失值,容易导致模型收敛到尖锐的最小值点,从而影响泛化性能。近年来,锐度感知最小化(Sharpness-Aware Minimization, SAM)通过同时最小化损失值和损失锐度,有效提升了模型的泛化能力,但其计算开销约为传统优化器的两倍。本文系统研究了SAM及其两种高效变体——ESA", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 64, + "score": 26, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-23", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ashh/jittor-chanshiguan-track2-straightpcf-denoise", + "description": "赛道二三维点云降噪项目,基于 StraightPCF 复现并改进三阶段耦合速度场方法:单速度预训练、双速度耦合训练、距离缩放模块精调。采用 patch 级迭代推理与稳健重建,保证输入输出点数一致。最佳提交成绩:72.63(CD 60.31 / P2S 84.96)。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 41, + "score": 24, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-06-27", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "clj111/Edge-Computing-Engine", + "description": "Edge : 一个开源的科学计算引擎", + "language": "C++", + "stars": 0, + "forks": 0, + "visits": 186, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2021-06-16", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "njuiselab/Gandalf", + "description": "", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 189, + "score": 18, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2024-10-11", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "ly15927029790/CodeRecommendation", + "description": "代码生成式补全工具", + "language": "Java", + "stars": 0, + "forks": 0, + "visits": 177, + "score": 17, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2020-10-12", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "co63oc/mcTVM", + "description": "mcTVM是MetaX-MACA生态下的开源深度学习编译框架项目,基于Apache TVM v0.18.0版本进行扩展开发,新增对沐曦(MetaX)GPU的专属支持,打通沐曦GPU与TVM框架的适配通道,实现深度学习模型在沐曦GPU上的高效编译、优化与部署。mcTVM助力完善沐曦GPU的软件生态,为开发者提供便捷、高效的深度学习模型部署解决方案,适用于人工智能、异构计算等相关领域的研发与应用场景。", + "language": "Python", + "stars": 0, + "forks": 0, + "visits": 66, + "score": 16, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2026-04-15", + "contributors_count": 0, + "releases_count": 0 + }, + { + "repo": "aaasdasd/aa", + "description": "", + "language": "Python3.6", + "stars": 0, + "forks": 0, + "visits": 146, + "score": 14, + "velocity": 0.0, + "matched_keywords": [ + "深度学习" + ], + "updated": "2023-07-15", + "contributors_count": 0, + "releases_count": 0 + } + ], + "active_discussions": [], + "topic_heat": [ + { + "topic": "deep_learning", + "count": 15 + }, + { + "topic": "scientific_computing", + "count": 7 + }, + { + "topic": "computer_vision", + "count": 6 + }, + { + "topic": "machine_learning", + "count": 4 + }, + { + "topic": "database", + "count": 3 + }, + { + "topic": "time_series", + "count": 3 + }, + { + "topic": "generative_ai", + "count": 3 + }, + { + "topic": "reinforcement_learning", + "count": 2 + }, + { + "topic": "nlp", + "count": 2 + }, + { + "topic": "autonomous_systems", + "count": 2 + } + ], + "core_scholars": [ + { + "login": "Edge", + "repo_count": 2, + "repos": [ + "Edgedev/Edge-Computing-Engine", + "clj111/Edge-Computing-Engine" + ] + }, + { + "login": "junru shao", + "repo_count": 2, + "repos": [ + "co63oc/mcTVM" + ] + }, + { + "login": "Edgedev", + "repo_count": 1, + "repos": [ + "Edgedev/Edge-Computing-Engine" + ] + }, + { + "login": "cloudy1225", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "CocytusXRS", + "repo_count": 1, + "repos": [ + "CocytusXRS/AI_test" + ] + }, + { + "login": "AIEven", + "repo_count": 1, + "repos": [ + "AIEven/AIkun" + ] + }, + { + "login": "shenyan", + "repo_count": 1, + "repos": [ + "shenyan/machineLearningTemplate" + ] + }, + { + "login": "jalew", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "wen-feng zeng", + "repo_count": 1, + "repos": [ + "opensci/pDeep" + ] + }, + { + "login": "dong liu", + "repo_count": 1, + "repos": [ + "dongeliu/dlvc" + ] + }, + { + "login": "404notfound233", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + }, + { + "login": "beginner401", + "repo_count": 1, + "repos": [ + "njuiselab/Gandalf" + ] + } + ], + "core_teams": [ + { + "login": "Edgedev", + "repo_count": 1 + }, + { + "login": "CocytusXRS", + "repo_count": 1 + }, + { + "login": "AIEven", + "repo_count": 1 + }, + { + "login": "shenyan", + "repo_count": 1 + }, + { + "login": "opensci", + "repo_count": 1 + }, + { + "login": "dongeliu", + "repo_count": 1 + }, + { + "login": "clj111", + "repo_count": 1 + }, + { + "login": "njuiselab", + "repo_count": 1 + } + ], + "meta": { + "repo_count": 13, + "issue_count": 2, + "pr_count": 0, + "scholar_count": 35, + "discussion_count": 0, + "topic_count": 10 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_082633/hotspot/report.md b/research-output/session_20260708_082633/hotspot/report.md new file mode 100644 index 0000000..d43fbbf --- /dev/null +++ b/research-output/session_20260708_082633/hotspot/report.md @@ -0,0 +1,50 @@ +# 🔬 科研热点追踪报告 + +> 关键词:深度学习 +> 扫描时间:2026-07-08 +> 覆盖仓库:13 个 · 讨论 0 条 · 主题 10 个 · 学者 35 位 + +## 🔥 飙升项目 Top 5 + +| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 | +|---|------|------|---------|--------|--------|------|------| +| 1 | `Edgedev/Edge-Computing-Engine` | C++ | 593 | 3 | 1 | 64 | 2021-07-13 | +| 2 | `CocytusXRS/AI_test` | Jupyter notebook | 550 | 0 | 0 | 55 | 2022-12-02 | +| 3 | `AIEven/AIkun` | Python | 382 | 0 | 0 | 38 | 2025-08-09 | +| 4 | `shenyan/machineLearningTemplate` | Python | 309 | 1 | 0 | 31 | 2021-10-22 | +| 5 | `opensci/pDeep` | Python | 292 | 0 | 0 | 29 | 2021-03-19 | + +## 📊 热门主题 + +- **deep_learning** — 15 个仓库 ███████████████ +- **scientific_computing** — 7 个仓库 ███████ +- **computer_vision** — 6 个仓库 ██████ +- **machine_learning** — 4 个仓库 ████ +- **database** — 3 个仓库 ███ +- **time_series** — 3 个仓库 ███ +- **generative_ai** — 3 个仓库 ███ +- **reinforcement_learning** — 2 个仓库 ██ +- **nlp** — 2 个仓库 ██ +- **autonomous_systems** — 2 个仓库 ██ + +## 👥 核心学者 + +- **Edge** — 关联 2 个仓库 +- **junru shao** — 关联 2 个仓库 +- **Edgedev** — 关联 1 个仓库 +- **cloudy1225** — 关联 1 个仓库 +- **CocytusXRS** — 关联 1 个仓库 + +## 🏛 活跃组织/团队 + +- **Edgedev** — 1 个仓库 +- **CocytusXRS** — 1 个仓库 +- **AIEven** — 1 个仓库 +- **shenyan** — 1 个仓库 +- **opensci** — 1 个仓库 +- **dongeliu** — 1 个仓库 +- **clj111** — 1 个仓库 +- **njuiselab** — 1 个仓库 + +--- +*由 gitlink-research-hotspot 自动生成* \ No newline at end of file diff --git a/research-output/session_20260708_082633/profile/profile.json b/research-output/session_20260708_082633/profile/profile.json new file mode 100644 index 0000000..b9389b9 --- /dev/null +++ b/research-output/session_20260708_082633/profile/profile.json @@ -0,0 +1,52 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "whale_hihihi/gitlink-cli", + "name": "gitlink-cli", + "description": "", + "topics": [ + { + "topic": "reinforcement_learning", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + } + ], + "languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell" + ], + "stars": 0, + "forks": 0, + "visits": 0, + "contributors_count": 20, + "top_contributors": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 10, + "impact": 0 + }, + "score_total": 15 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260708_082633/profile/report.md b/research-output/session_20260708_082633/profile/report.md new file mode 100644 index 0000000..903754b --- /dev/null +++ b/research-output/session_20260708_082633/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 gitlink-cli (`whale_hihihi/gitlink-cli`) + +- ★0 ⑂0 👁0 · 贡献者 20 · 研究维度评分 **15/40** +- 主题:`reinforcement_learning`、`devops` +- 核心贡献者:`wbtiger`、`whale`、`wauxing`、`Surponess`、`wangyue789`、`tiger` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260708_082633/report/report.json b/research-output/session_20260708_082633/report/report.json new file mode 100644 index 0000000..31898fa --- /dev/null +++ b/research-output/session_20260708_082633/report/report.json @@ -0,0 +1,80 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "whale_hihihi/gitlink-cli", + "generated_at": "2026-07-08T00:27:31.149904+00:00", + "week_stats": { + "this_week": { + "commits": 44, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 9, + "prs_merged": 8, + "prs_open_stale": 0, + "contributors_active": 7, + "contributors_active_logins": [ + "Surponess", + "baoerjun", + "wauxing", + "wbtiger", + "whale", + "whale_hihihi", + "yangsai01" + ] + }, + "last_week": { + "commits": 4, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 2, + "prs_merged": 2, + "prs_open_stale": 0, + "contributors_active": 1, + "contributors_active_logins": [ + "Surponess" + ] + }, + "window": { + "this_week_start": "2026-07-01T00:27:31.149904+00:00", + "now": "2026-07-08T00:27:31.149904+00:00", + "last_week_start": "2026-06-24T00:27:31.149904+00:00", + "last_week_end": "2026-07-01T00:27:31.149904+00:00" + }, + "total_contributors": 20 + }, + "trend": { + "commit_delta_pct": 1000.0, + "activity_level": "increasing", + "this_week_commits": 44, + "last_week_commits": 4 + }, + "milestones": [ + { + "name": "完成批量成员邀请", + "open": 0, + "closed": 0, + "total": 0, + "completion_pct": 0.0, + "due_date": "2026-06-04T00:00:00+00:00", + "overdue": true, + "status": "open" + } + ], + "risk_warnings": [ + { + "level": "critical", + "type": "overdue_milestone", + "message": "里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0%", + "metric": 0.0, + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。" + } + ], + "meta": { + "commits_fetched": 50, + "issues_fetched": 0, + "prs_fetched": 30, + "milestones_fetched": 1, + "contributors_fetched": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_082633/report/weekly_report.md b/research-output/session_20260708_082633/report/weekly_report.md new file mode 100644 index 0000000..35d2c50 --- /dev/null +++ b/research-output/session_20260708_082633/report/weekly_report.md @@ -0,0 +1,36 @@ +# 科研进度智能跟踪周报 — whale_hihihi/gitlink-cli + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-08T00:27:31.149904+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 44 | 4 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 9 | 2 | +| PR 合并 | 8 | 2 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 7 | 1 | + +- **趋势**:commit 周环比 **1000.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 | +|--------|-----------|--------|----------|------| +| 完成批量成员邀请 ⚠️逾期 | 0/0 | 0.0% | 2026-06-04T00:00:00+00:00 | open | + +## 三、风险预警 + +| 级别 | 类型 | 说明 | 建议 | +|------|------|------|------| +| critical | overdue_milestone | 里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0% | 重新评估范围或顺延 deadline,并同步干系人。 | + +## 四、附 + +- 取数:commits=50 issues=0 prs=30 milestones=1 contributors=20 +- 窗口:本周 [2026-07-01T00:27:31.149904+00:00, 2026-07-08T00:27:31.149904+00:00];上周 [2026-06-24T00:27:31.149904+00:00, 2026-07-01T00:27:31.149904+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260708_082633/repro/compliance_report.md b/research-output/session_20260708_082633/repro/compliance_report.md new file mode 100644 index 0000000..f48a199 --- /dev/null +++ b/research-output/session_20260708_082633/repro/compliance_report.md @@ -0,0 +1,48 @@ +# 科研项目合规与复现性检查报告 — whale_hihihi/gitlink-cli + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `MulanPSL-2.0` +- **复现性评分**: **9.0/10**(良好) +- **合规性评分**: **7.5/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | PASS | 2/2 | 检测到 CI 配置: .devops, .gitea, .github | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: go.sum | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 10 个: install, setup, build, 运行, run | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: Dockerfile | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | PASS | 2/2 | LICENSE 声明为 MulanPSL-2.0 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | PASS | 2/2 | LICENSE/README 中含 copyright/版权 声明 | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | FAIL | 1/2 | .gitignore 未忽略 .env(建议添加 .env) | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| high | privacy | 数据隐私 | | .gitignore 未忽略 .env(预防性建议) | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | +| medium | repro/compliance | .gitignore 忽略 .env | | .gitignore 未忽略 .env(建议添加 .env) | + +_复现分 9.0/10 · 合规分 7.5/10 · 树节点 31_ diff --git a/research-output/session_20260708_082633/repro/repro.json b/research-output/session_20260708_082633/repro/repro.json new file mode 100644 index 0000000..03ef7b8 --- /dev/null +++ b/research-output/session_20260708_082633/repro/repro.json @@ -0,0 +1,143 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "license": "MulanPSL-2.0", + "repro_items": [ + { + "name": "CI 配置", + "pass": true, + "score": 2, + "evidence": "检测到 CI 配置: .devops, .gitea, .github" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: go.sum" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 10 个: install, setup, build, 运行, run" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: Dockerfile" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": true, + "score": 2, + "evidence": "LICENSE 声明为 MulanPSL-2.0" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": true, + "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": false, + "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)" + } + ], + "secrets": [], + "risks": [ + { + "area": "privacy", + "name": "数据隐私", + "evidence": ".gitignore 未忽略 .env(预防性建议)", + "level": "high" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": ".gitignore 忽略 .env", + "evidence": ".gitignore 未忽略 .env(建议添加 .env)", + "level": "medium" + } + ], + "repro_score": 9.0, + "compliance_score": 7.5, + "meta": { + "key_files_found": [ + ".gitignore", + "LICENSE", + "README.md", + "go.mod" + ], + "tree_size": 31, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260708_082633/visual/report.md b/research-output/session_20260708_082633/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_082633/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_082633/visual/visual.json b/research-output/session_20260708_082633/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_082633/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/inspire/inspire.json b/research-output/session_20260708_083341/inspire/inspire.json new file mode 100644 index 0000000..7b0867a --- /dev/null +++ b/research-output/session_20260708_083341/inspire/inspire.json @@ -0,0 +1,181 @@ +{ + "scenario": "inspire", + "mode": "repo", + "repo": "whale_hihihi/gitlink-cli", + "gap_topics": [ + "reinforcement_learning", + "devops" + ], + "needed_languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell", + "go" + ], + "gap_signals": [], + "candidates": [ + { + "login": "wbtiger", + "score": 47.9, + "topic_overlap": 0.707, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 15, + "reasons": [ + "覆盖缺口主题: devops", + "本仓库活跃贡献者" + ] + }, + { + "login": "Mengz", + "score": 30.9, + "topic_overlap": 0.0, + "language_match": 0.091, + "activity_level": "high", + "repo_languages": [ + "c++", + "go", + "markdown", + "python" + ], + "repo_count": 15, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=5)" + ] + }, + { + "login": "muel", + "score": 28.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 5, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=4)" + ] + }, + { + "login": "puygob236", + "score": 22.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "baoerjun", + "score": 22.3, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript" + ], + "repo_count": 4, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "Surponess", + "score": 19.6, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript", + "markdown" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=1)" + ] + }, + { + "login": "whale", + "score": 14.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "wauxing", + "score": 14.7, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + } + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + } + ], + "idea": null, + "llm_used": false +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/inspire/report.md b/research-output/session_20260708_083341/inspire/report.md new file mode 100644 index 0000000..a636f47 --- /dev/null +++ b/research-output/session_20260708_083341/inspire/report.md @@ -0,0 +1,26 @@ +# 💡 创新启发报告 + +> 焦点仓库:`whale_hihihi/gitlink-cli` +> LLM 建议:⏭ 未启用(无 DEEPSEEK_API_KEY) + +## 🎯 缺口主题 +`reinforcement_learning`, `devops` + +## 🤝 可合作学者 Top 5 +| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 | +|---|---|---|---|---|---| +| `wbtiger` | 47.9 | 0.707 | 0.0 | high | 覆盖缺口主题: devops;本仓库活跃贡献者 | +| `Mengz` | 30.9 | 0.0 | 0.091 | high | 语言匹配: go;本仓库活跃贡献者 | +| `muel` | 28.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `puygob236` | 22.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `baoerjun` | 22.3 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | + +## 🌟 近期创新点 +- feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 _(大规模重构/新特性)_ +- 修改知识图谱构建方法 _(大规模重构/新特性)_ +- feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 _(大规模重构/新特性)_ +- 新增skills _(大规模重构/新特性)_ +- 新增demo _(大规模重构/新特性)_ + +--- +*由 gitlink-research-inspire 生成* \ No newline at end of file diff --git a/research-output/session_20260708_083341/lineage/branch_graph.mmd b/research-output/session_20260708_083341/lineage/branch_graph.mmd new file mode 100644 index 0000000..d060b31 --- /dev/null +++ b/research-output/session_20260708_083341/lineage/branch_graph.mmd @@ -0,0 +1,34 @@ +```mermaid +gitGraph + commit id: "main 起点" + commit + commit + commit + commit id: "#5" tag: "2026-05-29" + commit + commit + commit + commit id: "#12 创新点" tag: "2026-06-01" + commit + commit + commit + commit id: "#14 创新点" tag: "2026-06-03" + commit + commit + commit + commit id: "#18 创新点" tag: "2026-06-15" + commit + commit + commit + commit id: "#21" tag: "2026-06-29" + commit + commit + commit + commit id: "#31 创新点" tag: "2026-07-07" + commit + commit + commit + commit id: "#28" tag: "2026-07-07" + commit + commit +``` \ No newline at end of file diff --git a/research-output/session_20260708_083341/lineage/lineage.json b/research-output/session_20260708_083341/lineage/lineage.json new file mode 100644 index 0000000..c65af9a --- /dev/null +++ b/research-output/session_20260708_083341/lineage/lineage.json @@ -0,0 +1,271 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "main", + "commit_timeline": [ + { + "date": "2026-04-17", + "count": 10 + }, + { + "date": "2026-04-18", + "count": 5 + }, + { + "date": "2026-04-28", + "count": 1 + }, + { + "date": "2026-05-11", + "count": 1 + }, + { + "date": "2026-05-12", + "count": 6 + }, + { + "date": "2026-05-13", + "count": 1 + }, + { + "date": "2026-05-14", + "count": 2 + }, + { + "date": "2026-05-17", + "count": 1 + } + ], + "branch_map": [ + { + "name": "main", + "commits": 27, + "last_active": "2026-05-17", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "README.md", + "last_date": "" + }, + { + "file": "README.zh-CN.md", + "last_date": "" + } + ], + "experiment_files": [ + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 27, + "merged_pr_count": 23, + "doc_count": 2, + "experiment_file_count": 1 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/lineage/report.md b/research-output/session_20260708_083341/lineage/report.md new file mode 100644 index 0000000..8e7ea14 --- /dev/null +++ b/research-output/session_20260708_083341/lineage/report.md @@ -0,0 +1,67 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `main` +- **采样提交**: 27 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 2 个 +- **实验/评测文件**: 1 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-04-17 → 2026-05-17(共 8 个有提交的日期) +- 峰值: 2026-04-17 当日 10 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `main` | 27 | 2026-05-17 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| README.md | — | +| README.zh-CN.md | — | + +## 七、实验/评测文件组织 + +- `pr-test-file.txt` diff --git a/research-output/session_20260708_083341/profile/profile.json b/research-output/session_20260708_083341/profile/profile.json new file mode 100644 index 0000000..b9389b9 --- /dev/null +++ b/research-output/session_20260708_083341/profile/profile.json @@ -0,0 +1,52 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "whale_hihihi/gitlink-cli", + "name": "gitlink-cli", + "description": "", + "topics": [ + { + "topic": "reinforcement_learning", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + } + ], + "languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell" + ], + "stars": 0, + "forks": 0, + "visits": 0, + "contributors_count": 20, + "top_contributors": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 10, + "impact": 0 + }, + "score_total": 15 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/profile/report.md b/research-output/session_20260708_083341/profile/report.md new file mode 100644 index 0000000..903754b --- /dev/null +++ b/research-output/session_20260708_083341/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 gitlink-cli (`whale_hihihi/gitlink-cli`) + +- ★0 ⑂0 👁0 · 贡献者 20 · 研究维度评分 **15/40** +- 主题:`reinforcement_learning`、`devops` +- 核心贡献者:`wbtiger`、`whale`、`wauxing`、`Surponess`、`wangyue789`、`tiger` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260708_083341/report/report.json b/research-output/session_20260708_083341/report/report.json new file mode 100644 index 0000000..d0c1fb7 --- /dev/null +++ b/research-output/session_20260708_083341/report/report.json @@ -0,0 +1,80 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "whale_hihihi/gitlink-cli", + "generated_at": "2026-07-08T00:35:06.265226+00:00", + "week_stats": { + "this_week": { + "commits": 44, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 9, + "prs_merged": 8, + "prs_open_stale": 0, + "contributors_active": 7, + "contributors_active_logins": [ + "Surponess", + "baoerjun", + "wauxing", + "wbtiger", + "whale", + "whale_hihihi", + "yangsai01" + ] + }, + "last_week": { + "commits": 4, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 2, + "prs_merged": 2, + "prs_open_stale": 0, + "contributors_active": 1, + "contributors_active_logins": [ + "Surponess" + ] + }, + "window": { + "this_week_start": "2026-07-01T00:35:06.265226+00:00", + "now": "2026-07-08T00:35:06.265226+00:00", + "last_week_start": "2026-06-24T00:35:06.265226+00:00", + "last_week_end": "2026-07-01T00:35:06.265226+00:00" + }, + "total_contributors": 20 + }, + "trend": { + "commit_delta_pct": 1000.0, + "activity_level": "increasing", + "this_week_commits": 44, + "last_week_commits": 4 + }, + "milestones": [ + { + "name": "完成批量成员邀请", + "open": 0, + "closed": 0, + "total": 0, + "completion_pct": 0.0, + "due_date": "2026-06-04T00:00:00+00:00", + "overdue": true, + "status": "open" + } + ], + "risk_warnings": [ + { + "level": "critical", + "type": "overdue_milestone", + "message": "里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0%", + "metric": 0.0, + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。" + } + ], + "meta": { + "commits_fetched": 50, + "issues_fetched": 0, + "prs_fetched": 30, + "milestones_fetched": 1, + "contributors_fetched": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/report/weekly_report.md b/research-output/session_20260708_083341/report/weekly_report.md new file mode 100644 index 0000000..9b9093a --- /dev/null +++ b/research-output/session_20260708_083341/report/weekly_report.md @@ -0,0 +1,36 @@ +# 科研进度智能跟踪周报 — whale_hihihi/gitlink-cli + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-08T00:35:06.265226+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 44 | 4 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 9 | 2 | +| PR 合并 | 8 | 2 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 7 | 1 | + +- **趋势**:commit 周环比 **1000.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 | +|--------|-----------|--------|----------|------| +| 完成批量成员邀请 ⚠️逾期 | 0/0 | 0.0% | 2026-06-04T00:00:00+00:00 | open | + +## 三、风险预警 + +| 级别 | 类型 | 说明 | 建议 | +|------|------|------|------| +| critical | overdue_milestone | 里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0% | 重新评估范围或顺延 deadline,并同步干系人。 | + +## 四、附 + +- 取数:commits=50 issues=0 prs=30 milestones=1 contributors=20 +- 窗口:本周 [2026-07-01T00:35:06.265226+00:00, 2026-07-08T00:35:06.265226+00:00];上周 [2026-06-24T00:35:06.265226+00:00, 2026-07-01T00:35:06.265226+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260708_083341/repro/compliance_report.md b/research-output/session_20260708_083341/repro/compliance_report.md new file mode 100644 index 0000000..f48a199 --- /dev/null +++ b/research-output/session_20260708_083341/repro/compliance_report.md @@ -0,0 +1,48 @@ +# 科研项目合规与复现性检查报告 — whale_hihihi/gitlink-cli + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `MulanPSL-2.0` +- **复现性评分**: **9.0/10**(良好) +- **合规性评分**: **7.5/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | PASS | 2/2 | 检测到 CI 配置: .devops, .gitea, .github | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: go.sum | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 10 个: install, setup, build, 运行, run | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: Dockerfile | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | PASS | 2/2 | LICENSE 声明为 MulanPSL-2.0 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | PASS | 2/2 | LICENSE/README 中含 copyright/版权 声明 | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | FAIL | 1/2 | .gitignore 未忽略 .env(建议添加 .env) | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| high | privacy | 数据隐私 | | .gitignore 未忽略 .env(预防性建议) | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | +| medium | repro/compliance | .gitignore 忽略 .env | | .gitignore 未忽略 .env(建议添加 .env) | + +_复现分 9.0/10 · 合规分 7.5/10 · 树节点 31_ diff --git a/research-output/session_20260708_083341/repro/repro.json b/research-output/session_20260708_083341/repro/repro.json new file mode 100644 index 0000000..03ef7b8 --- /dev/null +++ b/research-output/session_20260708_083341/repro/repro.json @@ -0,0 +1,143 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "license": "MulanPSL-2.0", + "repro_items": [ + { + "name": "CI 配置", + "pass": true, + "score": 2, + "evidence": "检测到 CI 配置: .devops, .gitea, .github" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: go.sum" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 10 个: install, setup, build, 运行, run" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: Dockerfile" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": true, + "score": 2, + "evidence": "LICENSE 声明为 MulanPSL-2.0" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": true, + "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": false, + "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)" + } + ], + "secrets": [], + "risks": [ + { + "area": "privacy", + "name": "数据隐私", + "evidence": ".gitignore 未忽略 .env(预防性建议)", + "level": "high" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": ".gitignore 忽略 .env", + "evidence": ".gitignore 未忽略 .env(建议添加 .env)", + "level": "medium" + } + ], + "repro_score": 9.0, + "compliance_score": 7.5, + "meta": { + "key_files_found": [ + ".gitignore", + "LICENSE", + "README.md", + "go.mod" + ], + "tree_size": 31, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083341/visual/report.md b/research-output/session_20260708_083341/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_083341/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_083341/visual/visual.json b/research-output/session_20260708_083341/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_083341/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/inspire/inspire.json b/research-output/session_20260708_083522/inspire/inspire.json new file mode 100644 index 0000000..7b0867a --- /dev/null +++ b/research-output/session_20260708_083522/inspire/inspire.json @@ -0,0 +1,181 @@ +{ + "scenario": "inspire", + "mode": "repo", + "repo": "whale_hihihi/gitlink-cli", + "gap_topics": [ + "reinforcement_learning", + "devops" + ], + "needed_languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell", + "go" + ], + "gap_signals": [], + "candidates": [ + { + "login": "wbtiger", + "score": 47.9, + "topic_overlap": 0.707, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 15, + "reasons": [ + "覆盖缺口主题: devops", + "本仓库活跃贡献者" + ] + }, + { + "login": "Mengz", + "score": 30.9, + "topic_overlap": 0.0, + "language_match": 0.091, + "activity_level": "high", + "repo_languages": [ + "c++", + "go", + "markdown", + "python" + ], + "repo_count": 15, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=5)" + ] + }, + { + "login": "muel", + "score": 28.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 5, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=4)" + ] + }, + { + "login": "puygob236", + "score": 22.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "baoerjun", + "score": 22.3, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript" + ], + "repo_count": 4, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "Surponess", + "score": 19.6, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript", + "markdown" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=1)" + ] + }, + { + "login": "whale", + "score": 14.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "wauxing", + "score": 14.7, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + } + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + } + ], + "idea": null, + "llm_used": false +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/inspire/report.md b/research-output/session_20260708_083522/inspire/report.md new file mode 100644 index 0000000..a636f47 --- /dev/null +++ b/research-output/session_20260708_083522/inspire/report.md @@ -0,0 +1,26 @@ +# 💡 创新启发报告 + +> 焦点仓库:`whale_hihihi/gitlink-cli` +> LLM 建议:⏭ 未启用(无 DEEPSEEK_API_KEY) + +## 🎯 缺口主题 +`reinforcement_learning`, `devops` + +## 🤝 可合作学者 Top 5 +| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 | +|---|---|---|---|---|---| +| `wbtiger` | 47.9 | 0.707 | 0.0 | high | 覆盖缺口主题: devops;本仓库活跃贡献者 | +| `Mengz` | 30.9 | 0.0 | 0.091 | high | 语言匹配: go;本仓库活跃贡献者 | +| `muel` | 28.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `puygob236` | 22.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `baoerjun` | 22.3 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | + +## 🌟 近期创新点 +- feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 _(大规模重构/新特性)_ +- 修改知识图谱构建方法 _(大规模重构/新特性)_ +- feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 _(大规模重构/新特性)_ +- 新增skills _(大规模重构/新特性)_ +- 新增demo _(大规模重构/新特性)_ + +--- +*由 gitlink-research-inspire 生成* \ No newline at end of file diff --git a/research-output/session_20260708_083522/lineage/branch_graph.mmd b/research-output/session_20260708_083522/lineage/branch_graph.mmd new file mode 100644 index 0000000..d060b31 --- /dev/null +++ b/research-output/session_20260708_083522/lineage/branch_graph.mmd @@ -0,0 +1,34 @@ +```mermaid +gitGraph + commit id: "main 起点" + commit + commit + commit + commit id: "#5" tag: "2026-05-29" + commit + commit + commit + commit id: "#12 创新点" tag: "2026-06-01" + commit + commit + commit + commit id: "#14 创新点" tag: "2026-06-03" + commit + commit + commit + commit id: "#18 创新点" tag: "2026-06-15" + commit + commit + commit + commit id: "#21" tag: "2026-06-29" + commit + commit + commit + commit id: "#31 创新点" tag: "2026-07-07" + commit + commit + commit + commit id: "#28" tag: "2026-07-07" + commit + commit +``` \ No newline at end of file diff --git a/research-output/session_20260708_083522/lineage/lineage.json b/research-output/session_20260708_083522/lineage/lineage.json new file mode 100644 index 0000000..c65af9a --- /dev/null +++ b/research-output/session_20260708_083522/lineage/lineage.json @@ -0,0 +1,271 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "main", + "commit_timeline": [ + { + "date": "2026-04-17", + "count": 10 + }, + { + "date": "2026-04-18", + "count": 5 + }, + { + "date": "2026-04-28", + "count": 1 + }, + { + "date": "2026-05-11", + "count": 1 + }, + { + "date": "2026-05-12", + "count": 6 + }, + { + "date": "2026-05-13", + "count": 1 + }, + { + "date": "2026-05-14", + "count": 2 + }, + { + "date": "2026-05-17", + "count": 1 + } + ], + "branch_map": [ + { + "name": "main", + "commits": 27, + "last_active": "2026-05-17", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "README.md", + "last_date": "" + }, + { + "file": "README.zh-CN.md", + "last_date": "" + } + ], + "experiment_files": [ + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 27, + "merged_pr_count": 23, + "doc_count": 2, + "experiment_file_count": 1 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/lineage/report.md b/research-output/session_20260708_083522/lineage/report.md new file mode 100644 index 0000000..8e7ea14 --- /dev/null +++ b/research-output/session_20260708_083522/lineage/report.md @@ -0,0 +1,67 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `main` +- **采样提交**: 27 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 2 个 +- **实验/评测文件**: 1 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-04-17 → 2026-05-17(共 8 个有提交的日期) +- 峰值: 2026-04-17 当日 10 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `main` | 27 | 2026-05-17 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| README.md | — | +| README.zh-CN.md | — | + +## 七、实验/评测文件组织 + +- `pr-test-file.txt` diff --git a/research-output/session_20260708_083522/profile/profile.json b/research-output/session_20260708_083522/profile/profile.json new file mode 100644 index 0000000..b9389b9 --- /dev/null +++ b/research-output/session_20260708_083522/profile/profile.json @@ -0,0 +1,52 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "whale_hihihi/gitlink-cli", + "name": "gitlink-cli", + "description": "", + "topics": [ + { + "topic": "reinforcement_learning", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + } + ], + "languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell" + ], + "stars": 0, + "forks": 0, + "visits": 0, + "contributors_count": 20, + "top_contributors": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 10, + "impact": 0 + }, + "score_total": 15 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/profile/report.md b/research-output/session_20260708_083522/profile/report.md new file mode 100644 index 0000000..903754b --- /dev/null +++ b/research-output/session_20260708_083522/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 gitlink-cli (`whale_hihihi/gitlink-cli`) + +- ★0 ⑂0 👁0 · 贡献者 20 · 研究维度评分 **15/40** +- 主题:`reinforcement_learning`、`devops` +- 核心贡献者:`wbtiger`、`whale`、`wauxing`、`Surponess`、`wangyue789`、`tiger` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260708_083522/report/report.json b/research-output/session_20260708_083522/report/report.json new file mode 100644 index 0000000..fba0d8c --- /dev/null +++ b/research-output/session_20260708_083522/report/report.json @@ -0,0 +1,80 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "whale_hihihi/gitlink-cli", + "generated_at": "2026-07-08T00:36:41.377048+00:00", + "week_stats": { + "this_week": { + "commits": 44, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 9, + "prs_merged": 8, + "prs_open_stale": 0, + "contributors_active": 7, + "contributors_active_logins": [ + "Surponess", + "baoerjun", + "wauxing", + "wbtiger", + "whale", + "whale_hihihi", + "yangsai01" + ] + }, + "last_week": { + "commits": 4, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 2, + "prs_merged": 2, + "prs_open_stale": 0, + "contributors_active": 1, + "contributors_active_logins": [ + "Surponess" + ] + }, + "window": { + "this_week_start": "2026-07-01T00:36:41.377048+00:00", + "now": "2026-07-08T00:36:41.377048+00:00", + "last_week_start": "2026-06-24T00:36:41.377048+00:00", + "last_week_end": "2026-07-01T00:36:41.377048+00:00" + }, + "total_contributors": 20 + }, + "trend": { + "commit_delta_pct": 1000.0, + "activity_level": "increasing", + "this_week_commits": 44, + "last_week_commits": 4 + }, + "milestones": [ + { + "name": "完成批量成员邀请", + "open": 0, + "closed": 0, + "total": 0, + "completion_pct": 0.0, + "due_date": "2026-06-04T00:00:00+00:00", + "overdue": true, + "status": "open" + } + ], + "risk_warnings": [ + { + "level": "critical", + "type": "overdue_milestone", + "message": "里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0%", + "metric": 0.0, + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。" + } + ], + "meta": { + "commits_fetched": 50, + "issues_fetched": 0, + "prs_fetched": 30, + "milestones_fetched": 1, + "contributors_fetched": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/report/weekly_report.md b/research-output/session_20260708_083522/report/weekly_report.md new file mode 100644 index 0000000..e5023ea --- /dev/null +++ b/research-output/session_20260708_083522/report/weekly_report.md @@ -0,0 +1,36 @@ +# 科研进度智能跟踪周报 — whale_hihihi/gitlink-cli + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-08T00:36:41.377048+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 44 | 4 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 9 | 2 | +| PR 合并 | 8 | 2 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 7 | 1 | + +- **趋势**:commit 周环比 **1000.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 | +|--------|-----------|--------|----------|------| +| 完成批量成员邀请 ⚠️逾期 | 0/0 | 0.0% | 2026-06-04T00:00:00+00:00 | open | + +## 三、风险预警 + +| 级别 | 类型 | 说明 | 建议 | +|------|------|------|------| +| critical | overdue_milestone | 里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0% | 重新评估范围或顺延 deadline,并同步干系人。 | + +## 四、附 + +- 取数:commits=50 issues=0 prs=30 milestones=1 contributors=20 +- 窗口:本周 [2026-07-01T00:36:41.377048+00:00, 2026-07-08T00:36:41.377048+00:00];上周 [2026-06-24T00:36:41.377048+00:00, 2026-07-01T00:36:41.377048+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260708_083522/repro/compliance_report.md b/research-output/session_20260708_083522/repro/compliance_report.md new file mode 100644 index 0000000..f48a199 --- /dev/null +++ b/research-output/session_20260708_083522/repro/compliance_report.md @@ -0,0 +1,48 @@ +# 科研项目合规与复现性检查报告 — whale_hihihi/gitlink-cli + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `MulanPSL-2.0` +- **复现性评分**: **9.0/10**(良好) +- **合规性评分**: **7.5/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | PASS | 2/2 | 检测到 CI 配置: .devops, .gitea, .github | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: go.sum | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 10 个: install, setup, build, 运行, run | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: Dockerfile | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | PASS | 2/2 | LICENSE 声明为 MulanPSL-2.0 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | PASS | 2/2 | LICENSE/README 中含 copyright/版权 声明 | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | FAIL | 1/2 | .gitignore 未忽略 .env(建议添加 .env) | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| high | privacy | 数据隐私 | | .gitignore 未忽略 .env(预防性建议) | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | +| medium | repro/compliance | .gitignore 忽略 .env | | .gitignore 未忽略 .env(建议添加 .env) | + +_复现分 9.0/10 · 合规分 7.5/10 · 树节点 31_ diff --git a/research-output/session_20260708_083522/repro/repro.json b/research-output/session_20260708_083522/repro/repro.json new file mode 100644 index 0000000..03ef7b8 --- /dev/null +++ b/research-output/session_20260708_083522/repro/repro.json @@ -0,0 +1,143 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "license": "MulanPSL-2.0", + "repro_items": [ + { + "name": "CI 配置", + "pass": true, + "score": 2, + "evidence": "检测到 CI 配置: .devops, .gitea, .github" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: go.sum" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 10 个: install, setup, build, 运行, run" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: Dockerfile" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": true, + "score": 2, + "evidence": "LICENSE 声明为 MulanPSL-2.0" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": true, + "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": false, + "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)" + } + ], + "secrets": [], + "risks": [ + { + "area": "privacy", + "name": "数据隐私", + "evidence": ".gitignore 未忽略 .env(预防性建议)", + "level": "high" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": ".gitignore 忽略 .env", + "evidence": ".gitignore 未忽略 .env(建议添加 .env)", + "level": "medium" + } + ], + "repro_score": 9.0, + "compliance_score": 7.5, + "meta": { + "key_files_found": [ + ".gitignore", + "LICENSE", + "README.md", + "go.mod" + ], + "tree_size": 31, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083522/visual/report.md b/research-output/session_20260708_083522/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_083522/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_083522/visual/visual.json b/research-output/session_20260708_083522/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_083522/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_083810/visual/report.md b/research-output/session_20260708_083810/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_083810/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_083810/visual/visual.html b/research-output/session_20260708_083810/visual/visual.html new file mode 100644 index 0000000..0033583 --- /dev/null +++ b/research-output/session_20260708_083810/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260708_083810/visual/visual.json b/research-output/session_20260708_083810/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_083810/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/inspire/inspire.json b/research-output/session_20260708_084350/inspire/inspire.json new file mode 100644 index 0000000..7b0867a --- /dev/null +++ b/research-output/session_20260708_084350/inspire/inspire.json @@ -0,0 +1,181 @@ +{ + "scenario": "inspire", + "mode": "repo", + "repo": "whale_hihihi/gitlink-cli", + "gap_topics": [ + "reinforcement_learning", + "devops" + ], + "needed_languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell", + "go" + ], + "gap_signals": [], + "candidates": [ + { + "login": "wbtiger", + "score": 47.9, + "topic_overlap": 0.707, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 15, + "reasons": [ + "覆盖缺口主题: devops", + "本仓库活跃贡献者" + ] + }, + { + "login": "Mengz", + "score": 30.9, + "topic_overlap": 0.0, + "language_match": 0.091, + "activity_level": "high", + "repo_languages": [ + "c++", + "go", + "markdown", + "python" + ], + "repo_count": 15, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=5)" + ] + }, + { + "login": "muel", + "score": 28.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 5, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=4)" + ] + }, + { + "login": "puygob236", + "score": 22.4, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "typescript" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "baoerjun", + "score": 22.3, + "topic_overlap": 0.0, + "language_match": 0.111, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript" + ], + "repo_count": 4, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=2)" + ] + }, + { + "login": "Surponess", + "score": 19.6, + "topic_overlap": 0.0, + "language_match": 0.1, + "activity_level": "high", + "repo_languages": [ + "go", + "javascript", + "markdown" + ], + "repo_count": 3, + "reasons": [ + "语言匹配: go", + "本仓库活跃贡献者", + "协作开放度高(fork=1)" + ] + }, + { + "login": "whale", + "score": 14.8, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + }, + { + "login": "wauxing", + "score": 14.7, + "topic_overlap": 0.0, + "language_match": 0.0, + "activity_level": "high", + "repo_languages": [], + "repo_count": 0, + "reasons": [ + "本仓库活跃贡献者" + ] + } + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + } + ], + "idea": null, + "llm_used": false +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/inspire/report.md b/research-output/session_20260708_084350/inspire/report.md new file mode 100644 index 0000000..a636f47 --- /dev/null +++ b/research-output/session_20260708_084350/inspire/report.md @@ -0,0 +1,26 @@ +# 💡 创新启发报告 + +> 焦点仓库:`whale_hihihi/gitlink-cli` +> LLM 建议:⏭ 未启用(无 DEEPSEEK_API_KEY) + +## 🎯 缺口主题 +`reinforcement_learning`, `devops` + +## 🤝 可合作学者 Top 5 +| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 | +|---|---|---|---|---|---| +| `wbtiger` | 47.9 | 0.707 | 0.0 | high | 覆盖缺口主题: devops;本仓库活跃贡献者 | +| `Mengz` | 30.9 | 0.0 | 0.091 | high | 语言匹配: go;本仓库活跃贡献者 | +| `muel` | 28.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `puygob236` | 22.4 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | +| `baoerjun` | 22.3 | 0.0 | 0.111 | high | 语言匹配: go;本仓库活跃贡献者 | + +## 🌟 近期创新点 +- feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 _(大规模重构/新特性)_ +- 修改知识图谱构建方法 _(大规模重构/新特性)_ +- feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 _(大规模重构/新特性)_ +- 新增skills _(大规模重构/新特性)_ +- 新增demo _(大规模重构/新特性)_ + +--- +*由 gitlink-research-inspire 生成* \ No newline at end of file diff --git a/research-output/session_20260708_084350/lineage/branch_graph.mmd b/research-output/session_20260708_084350/lineage/branch_graph.mmd new file mode 100644 index 0000000..4817d5c --- /dev/null +++ b/research-output/session_20260708_084350/lineage/branch_graph.mmd @@ -0,0 +1,80 @@ +```mermaid +gitGraph + commit id: "main 起点" + branch pr-2 + checkout pr-2 + commit id: "PR#2: 本次实现:search +issues — 搜索 Issue" + checkout main + merge pr-2 + branch pr-3 + checkout pr-3 + commit id: "PR#3: 创建 webhook 领域 + 实现 `+update`" + checkout main + merge pr-3 + branch pr-5 + checkout pr-5 + commit id: "PR#5: release download 功能(新增) 在 rel" + checkout main + merge pr-5 + branch pr-4 + checkout pr-4 + commit id: "PR#4: label 领域原来只有 3 个命令(list/create" + checkout main + merge pr-4 + branch pr-6 + checkout pr-6 + commit id: "PR#6: release download 命令 新增 downloa✨创新" + checkout main + merge pr-6 + branch pr-12 + checkout pr-12 + commit id: "PR#12: feat: 实现 batch issue 批量操作命令✨创新" + checkout main + merge pr-12 + branch pr-8 + checkout pr-8 + commit id: "PR#8: 文件描述符泄漏等修改" + checkout main + merge pr-8 + branch pr-7 + checkout pr-7 + commit id: "PR#7: 代码片段管理功能" + checkout main + merge pr-7 + branch pr-14 + checkout pr-14 + commit id: "PR#14: feat: 补全 21 个 Shortcut + 修正批量操✨创新" + checkout main + merge pr-14 + branch pr-15 + checkout pr-15 + commit id: "PR#15: 修改编译" + checkout main + merge pr-15 + branch pr-16 + checkout pr-16 + commit id: "PR#16: 新增skills✨创新" + checkout main + merge pr-16 + branch pr-18 + checkout pr-18 + commit id: "PR#18: feat: 合并 upstream/master 并对齐风格✨创新" + checkout main + merge pr-18 + branch pr-19 + checkout pr-19 + commit id: "PR#19: 新增skills" + checkout main + merge pr-19 + branch pr-20 + checkout pr-20 + commit id: "PR#20: 注册重编激活 25 域、新增 6 个 Skill、补全索引与" + checkout main + merge pr-20 + branch pr-21 + checkout pr-21 + commit id: "PR#21: 验证部分内容,作出相关修改" + checkout main + merge pr-21 + commit id: "HEAD" +``` \ No newline at end of file diff --git a/research-output/session_20260708_084350/lineage/lineage.json b/research-output/session_20260708_084350/lineage/lineage.json new file mode 100644 index 0000000..c65af9a --- /dev/null +++ b/research-output/session_20260708_084350/lineage/lineage.json @@ -0,0 +1,271 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "main", + "commit_timeline": [ + { + "date": "2026-04-17", + "count": 10 + }, + { + "date": "2026-04-18", + "count": 5 + }, + { + "date": "2026-04-28", + "count": 1 + }, + { + "date": "2026-05-11", + "count": 1 + }, + { + "date": "2026-05-12", + "count": 6 + }, + { + "date": "2026-05-13", + "count": 1 + }, + { + "date": "2026-05-14", + "count": 2 + }, + { + "date": "2026-05-17", + "count": 1 + } + ], + "branch_map": [ + { + "name": "main", + "commits": 27, + "last_active": "2026-05-17", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "README.md", + "last_date": "" + }, + { + "file": "README.zh-CN.md", + "last_date": "" + } + ], + "experiment_files": [ + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 27, + "merged_pr_count": 23, + "doc_count": 2, + "experiment_file_count": 1 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/lineage/report.md b/research-output/session_20260708_084350/lineage/report.md new file mode 100644 index 0000000..8e7ea14 --- /dev/null +++ b/research-output/session_20260708_084350/lineage/report.md @@ -0,0 +1,67 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `main` +- **采样提交**: 27 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 2 个 +- **实验/评测文件**: 1 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-04-17 → 2026-05-17(共 8 个有提交的日期) +- 峰值: 2026-04-17 当日 10 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `main` | 27 | 2026-05-17 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| README.md | — | +| README.zh-CN.md | — | + +## 七、实验/评测文件组织 + +- `pr-test-file.txt` diff --git a/research-output/session_20260708_084350/profile/profile.json b/research-output/session_20260708_084350/profile/profile.json new file mode 100644 index 0000000..b9389b9 --- /dev/null +++ b/research-output/session_20260708_084350/profile/profile.json @@ -0,0 +1,52 @@ +{ + "scenario": "profile", + "mode": "project", + "profiles": [ + { + "type": "project", + "repo": "whale_hihihi/gitlink-cli", + "name": "gitlink-cli", + "description": "", + "topics": [ + { + "topic": "reinforcement_learning", + "count": 1 + }, + { + "topic": "devops", + "count": 1 + } + ], + "languages": [ + "Dockerfile", + "Go", + "HTML", + "JavaScript", + "Mermaid", + "Python", + "Shell" + ], + "stars": 0, + "forks": 0, + "visits": 0, + "contributors_count": 20, + "top_contributors": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236" + ], + "score": { + "doc": 5, + "license": 0, + "collab": 10, + "impact": 0 + }, + "score_total": 15 + } + ] +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/profile/report.md b/research-output/session_20260708_084350/profile/report.md new file mode 100644 index 0000000..903754b --- /dev/null +++ b/research-output/session_20260708_084350/profile/report.md @@ -0,0 +1,10 @@ +# 🪪 主体画像报告 + +## 📦 gitlink-cli (`whale_hihihi/gitlink-cli`) + +- ★0 ⑂0 👁0 · 贡献者 20 · 研究维度评分 **15/40** +- 主题:`reinforcement_learning`、`devops` +- 核心贡献者:`wbtiger`、`whale`、`wauxing`、`Surponess`、`wangyue789`、`tiger` + +--- +*由 gitlink-research-profile 生成* \ No newline at end of file diff --git a/research-output/session_20260708_084350/report/report.json b/research-output/session_20260708_084350/report/report.json new file mode 100644 index 0000000..2daa566 --- /dev/null +++ b/research-output/session_20260708_084350/report/report.json @@ -0,0 +1,80 @@ +{ + "scenario": "S5_progress_tracking", + "repo": "whale_hihihi/gitlink-cli", + "generated_at": "2026-07-08T00:45:32.919626+00:00", + "week_stats": { + "this_week": { + "commits": 44, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 9, + "prs_merged": 8, + "prs_open_stale": 0, + "contributors_active": 7, + "contributors_active_logins": [ + "Surponess", + "baoerjun", + "wauxing", + "wbtiger", + "whale", + "whale_hihihi", + "yangsai01" + ] + }, + "last_week": { + "commits": 4, + "issues_opened": 0, + "issues_closed": 0, + "issues_stale": 0, + "prs_opened": 2, + "prs_merged": 2, + "prs_open_stale": 0, + "contributors_active": 1, + "contributors_active_logins": [ + "Surponess" + ] + }, + "window": { + "this_week_start": "2026-07-01T00:45:32.919626+00:00", + "now": "2026-07-08T00:45:32.919626+00:00", + "last_week_start": "2026-06-24T00:45:32.919626+00:00", + "last_week_end": "2026-07-01T00:45:32.919626+00:00" + }, + "total_contributors": 20 + }, + "trend": { + "commit_delta_pct": 1000.0, + "activity_level": "increasing", + "this_week_commits": 44, + "last_week_commits": 4 + }, + "milestones": [ + { + "name": "完成批量成员邀请", + "open": 0, + "closed": 0, + "total": 0, + "completion_pct": 0.0, + "due_date": "2026-06-04T00:00:00+00:00", + "overdue": true, + "status": "open" + } + ], + "risk_warnings": [ + { + "level": "critical", + "type": "overdue_milestone", + "message": "里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0%", + "metric": 0.0, + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。" + } + ], + "meta": { + "commits_fetched": 50, + "issues_fetched": 0, + "prs_fetched": 30, + "milestones_fetched": 1, + "contributors_fetched": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/report/weekly_report.md b/research-output/session_20260708_084350/report/weekly_report.md new file mode 100644 index 0000000..a35de6c --- /dev/null +++ b/research-output/session_20260708_084350/report/weekly_report.md @@ -0,0 +1,36 @@ +# 科研进度智能跟踪周报 — whale_hihihi/gitlink-cli + +> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 2026-07-08T00:45:32.919626+00:00 + +## 一、本周 / 上周活动对比 + +| 指标 | 本周 | 上周 | +|------|------|------| +| 提交 commits | 44 | 4 | +| Issue 新增 | 0 | 0 | +| Issue 关闭 | 0 | 0 | +| 开放 stale issue (>30天) | 0 | 0 | +| PR 新增 | 9 | 2 | +| PR 合并 | 8 | 2 | +| 开放 stale PR (>14天) | 0 | 0 | +| 活跃贡献者 | 7 | 1 | + +- **趋势**:commit 周环比 **1000.0%**,活跃度等级 `increasing` + +## 二、里程碑进度 + +| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 | +|--------|-----------|--------|----------|------| +| 完成批量成员邀请 ⚠️逾期 | 0/0 | 0.0% | 2026-06-04T00:00:00+00:00 | open | + +## 三、风险预警 + +| 级别 | 类型 | 说明 | 建议 | +|------|------|------|------| +| critical | overdue_milestone | 里程碑「完成批量成员邀请」已逾期(due 2026-06-04T00:00:00+00:00),完成率 0.0% | 重新评估范围或顺延 deadline,并同步干系人。 | + +## 四、附 + +- 取数:commits=50 issues=0 prs=30 milestones=1 contributors=20 +- 窗口:本周 [2026-07-01T00:45:32.919626+00:00, 2026-07-08T00:45:32.919626+00:00];上周 [2026-06-24T00:45:32.919626+00:00, 2026-07-01T00:45:32.919626+00:00) +- 阈值:stale_issue>30天 / stale_pr>14天 / 低活跃<3次/周 / bus_factor>50% diff --git a/research-output/session_20260708_084350/repro/compliance_report.md b/research-output/session_20260708_084350/repro/compliance_report.md new file mode 100644 index 0000000..f48a199 --- /dev/null +++ b/research-output/session_20260708_084350/repro/compliance_report.md @@ -0,0 +1,48 @@ +# 科研项目合规与复现性检查报告 — whale_hihihi/gitlink-cli + +> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」 + +- **默认分支**: `master` +- **识别许可证**: `MulanPSL-2.0` +- **复现性评分**: **9.0/10**(良好) +- **合规性评分**: **7.5/10**(及格) + +## 一、复现性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| CI 配置 | PASS | 2/2 | 检测到 CI 配置: .devops, .gitea, .github | +| 依赖锁文件 | PASS | 2/2 | 存在 lockfile: go.sum | +| README 复现说明 | PASS | 2/2 | README 含复现关键词 10 个: install, setup, build, 运行, run | +| 版本 tag | FAIL | 1/2 | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| 容器化环境 | PASS | 2/2 | 存在容器配置: Dockerfile | + +## 二、合规性检查清单 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| LICENSE 文件 | PASS | 2/2 | LICENSE 声明为 MulanPSL-2.0 | +| 安全策略 SECURITY.md | FAIL | 0/2 | 缺少 SECURITY.md,无安全披露流程 | +| 版权声明 | PASS | 2/2 | LICENSE/README 中含 copyright/版权 声明 | +| 依赖清单声明 | PASS | 2/2 | 存在依赖管理文件(建议核对各依赖许可证兼容性) | +| 贡献指南 | FAIL | 1/2 | 缺少 CONTRIBUTING.md | + +## 三、数据隐私检查 + +| 检查项 | 通过 | 得分 | 证据 | +|--------|:----:|:----:|------| +| 数据目录入库 | PASS | 2/2 | 未发现 data/ 目录入库 | +| .env 入库 | PASS | 2/2 | .env 未入库 | +| .gitignore 忽略 .env | FAIL | 1/2 | .gitignore 未忽略 .env(建议添加 .env) | + +## 四、风险项(按严重程度排序) + +| 级别 | 类别 | 名称 | 文件:行 | 证据 | +|:----:|------|------|---------|------| +| high | privacy | 数据隐私 | | .gitignore 未忽略 .env(预防性建议) | +| medium | repro/compliance | 版本 tag | | repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本 | +| medium | repro/compliance | 安全策略 SECURITY.md | | 缺少 SECURITY.md,无安全披露流程 | +| medium | repro/compliance | 贡献指南 | | 缺少 CONTRIBUTING.md | +| medium | repro/compliance | .gitignore 忽略 .env | | .gitignore 未忽略 .env(建议添加 .env) | + +_复现分 9.0/10 · 合规分 7.5/10 · 树节点 31_ diff --git a/research-output/session_20260708_084350/repro/repro.json b/research-output/session_20260708_084350/repro/repro.json new file mode 100644 index 0000000..03ef7b8 --- /dev/null +++ b/research-output/session_20260708_084350/repro/repro.json @@ -0,0 +1,143 @@ +{ + "scenario": "S3_compliance_reproducibility", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "master", + "license": "MulanPSL-2.0", + "repro_items": [ + { + "name": "CI 配置", + "pass": true, + "score": 2, + "evidence": "检测到 CI 配置: .devops, .gitea, .github" + }, + { + "name": "依赖锁文件", + "pass": true, + "score": 2, + "evidence": "存在 lockfile: go.sum" + }, + { + "name": "README 复现说明", + "pass": true, + "score": 2, + "evidence": "README 含复现关键词 10 个: install, setup, build, 运行, run" + }, + { + "name": "版本 tag", + "pass": false, + "score": 1, + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本" + }, + { + "name": "容器化环境", + "pass": true, + "score": 2, + "evidence": "存在容器配置: Dockerfile" + } + ], + "compliance_items": [ + { + "name": "LICENSE 文件", + "pass": true, + "score": 2, + "evidence": "LICENSE 声明为 MulanPSL-2.0" + }, + { + "name": "安全策略 SECURITY.md", + "pass": false, + "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程" + }, + { + "name": "版权声明", + "pass": true, + "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明" + }, + { + "name": "依赖清单声明", + "pass": true, + "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)" + }, + { + "name": "贡献指南", + "pass": false, + "score": 1, + "evidence": "缺少 CONTRIBUTING.md" + } + ], + "privacy_items": [ + { + "name": "数据目录入库", + "pass": true, + "score": 2, + "evidence": "未发现 data/ 目录入库" + }, + { + "name": ".env 入库", + "pass": true, + "score": 2, + "evidence": ".env 未入库" + }, + { + "name": ".gitignore 忽略 .env", + "pass": false, + "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)" + } + ], + "secrets": [], + "risks": [ + { + "area": "privacy", + "name": "数据隐私", + "evidence": ".gitignore 未忽略 .env(预防性建议)", + "level": "high" + }, + { + "area": "repro/compliance", + "name": "版本 tag", + "evidence": "repo_info 无显式 tag 字段(默认分支: master),建议打 tag 固定可复现版本", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "安全策略 SECURITY.md", + "evidence": "缺少 SECURITY.md,无安全披露流程", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": "贡献指南", + "evidence": "缺少 CONTRIBUTING.md", + "level": "medium" + }, + { + "area": "repro/compliance", + "name": ".gitignore 忽略 .env", + "evidence": ".gitignore 未忽略 .env(建议添加 .env)", + "level": "medium" + } + ], + "repro_score": 9.0, + "compliance_score": 7.5, + "meta": { + "key_files_found": [ + ".gitignore", + "LICENSE", + "README.md", + "go.mod" + ], + "tree_size": 31, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + } + } +} \ No newline at end of file diff --git a/research-output/session_20260708_084350/visual/report.md b/research-output/session_20260708_084350/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_084350/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_084350/visual/visual.html b/research-output/session_20260708_084350/visual/visual.html new file mode 100644 index 0000000..9a434a7 --- /dev/null +++ b/research-output/session_20260708_084350/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260708_084350/visual/visual.json b/research-output/session_20260708_084350/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_084350/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_085135/lineage/branch_graph.mmd b/research-output/session_20260708_085135/lineage/branch_graph.mmd new file mode 100644 index 0000000..7a8356a --- /dev/null +++ b/research-output/session_20260708_085135/lineage/branch_graph.mmd @@ -0,0 +1,80 @@ +```mermaid +gitGraph + commit id: "main 起点" + branch pr2 + checkout pr2 + commit id: "#2: 本次实现:search +issues — 搜索" + checkout main + merge pr2 + branch pr3 + checkout pr3 + commit id: "#3: 创建 webhook 领域 + 实现 `+upd" + checkout main + merge pr3 + branch pr5 + checkout pr5 + commit id: "#5: release download 功能(新增) " + checkout main + merge pr5 + branch pr4 + checkout pr4 + commit id: "#4: label 领域原来只有 3 个命令(list/" + checkout main + merge pr4 + branch pr6 + checkout pr6 + commit id: "#6 ✨创新: release download 命令 新增 d" + checkout main + merge pr6 + branch pr12 + checkout pr12 + commit id: "#12 ✨创新: feat: 实现 batch issue 批量操" + checkout main + merge pr12 + branch pr8 + checkout pr8 + commit id: "#8: 文件描述符泄漏等修改" + checkout main + merge pr8 + branch pr7 + checkout pr7 + commit id: "#7: 代码片段管理功能" + checkout main + merge pr7 + branch pr14 + checkout pr14 + commit id: "#14 ✨创新: feat: 补全 21 个 Shortcut +" + checkout main + merge pr14 + branch pr15 + checkout pr15 + commit id: "#15: 修改编译" + checkout main + merge pr15 + branch pr16 + checkout pr16 + commit id: "#16 ✨创新: 新增skills" + checkout main + merge pr16 + branch pr18 + checkout pr18 + commit id: "#18 ✨创新: feat: 合并 upstream/master" + checkout main + merge pr18 + branch pr19 + checkout pr19 + commit id: "#19: 新增skills" + checkout main + merge pr19 + branch pr20 + checkout pr20 + commit id: "#20: 注册重编激活 25 域、新增 6 个 Skill" + checkout main + merge pr20 + branch pr21 + checkout pr21 + commit id: "#21: 验证部分内容,作出相关修改" + checkout main + merge pr21 + commit id: "HEAD" +``` \ No newline at end of file diff --git a/research-output/session_20260708_085135/lineage/lineage.json b/research-output/session_20260708_085135/lineage/lineage.json new file mode 100644 index 0000000..c65af9a --- /dev/null +++ b/research-output/session_20260708_085135/lineage/lineage.json @@ -0,0 +1,271 @@ +{ + "scenario": "S1_repository_research_insight", + "repo": "whale_hihihi/gitlink-cli", + "default_branch": "main", + "commit_timeline": [ + { + "date": "2026-04-17", + "count": 10 + }, + { + "date": "2026-04-18", + "count": 5 + }, + { + "date": "2026-04-28", + "count": 1 + }, + { + "date": "2026-05-11", + "count": 1 + }, + { + "date": "2026-05-12", + "count": 6 + }, + { + "date": "2026-05-13", + "count": 1 + }, + { + "date": "2026-05-14", + "count": 2 + }, + { + "date": "2026-05-17", + "count": 1 + } + ], + "branch_map": [ + { + "name": "main", + "commits": 27, + "last_active": "2026-05-17", + "is_default": true + } + ], + "pr_merge_patterns": [ + { + "number": 2, + "title": "本次实现:search +issues — 搜索 Issue 功能", + "status": "merged", + "merged_time": "2026-05-25", + "changed_files": 4 + }, + { + "number": 3, + "title": "创建 webhook 领域 + 实现 `+update`", + "status": "merged", + "merged_time": "2026-05-28", + "changed_files": 2 + }, + { + "number": 5, + "title": "release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:-", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 7 + }, + { + "number": 4, + "title": "label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。", + "status": "merged", + "merged_time": "2026-05-29", + "changed_files": 3 + }, + { + "number": 6, + "title": "release download 命令 新增 download 快捷命令、错误消息统一化", + "status": "merged", + "merged_time": "2026-05-30", + "changed_files": 8 + }, + { + "number": 12, + "title": "feat: 实现 batch issue 批量操作命令", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 14 + }, + { + "number": 8, + "title": "文件描述符泄漏等修改", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 7, + "title": "代码片段管理功能", + "status": "merged", + "merged_time": "2026-06-01", + "changed_files": 5 + }, + { + "number": 14, + "title": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "status": "merged", + "merged_time": "2026-06-03", + "changed_files": 31 + }, + { + "number": 15, + "title": "修改编译", + "status": "merged", + "merged_time": "2026-06-04", + "changed_files": 9 + }, + { + "number": 16, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-12", + "changed_files": 26 + }, + { + "number": 18, + "title": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "status": "merged", + "merged_time": "2026-06-15", + "changed_files": 151 + }, + { + "number": 19, + "title": "新增skills", + "status": "merged", + "merged_time": "2026-06-23", + "changed_files": 7 + }, + { + "number": 20, + "title": "注册重编激活 25 域、新增 6 个 Skill、补全索引与示例、产出任务三/四的端到端工作流", + "status": "merged", + "merged_time": "2026-06-24", + "changed_files": 5 + }, + { + "number": 21, + "title": "验证部分内容,作出相关修改", + "status": "merged", + "merged_time": "2026-06-29", + "changed_files": 1 + }, + { + "number": 22, + "title": "修改了两个skills的命令使用", + "status": "merged", + "merged_time": "2026-07-02", + "changed_files": 2 + }, + { + "number": 24, + "title": "新增demo", + "status": "merged", + "merged_time": "2026-07-06", + "changed_files": 19 + }, + { + "number": 31, + "title": "修改知识图谱构建方法", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 111 + }, + { + "number": 30, + "title": "fix(sweep): wiki 发布链路修复 + 端到端验证", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 7 + }, + { + "number": 29, + "title": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 13 + }, + { + "number": 28, + "title": "feat(demo): 调整 demo 前端展示(合并 surponess_br 第二批)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 2 + }, + { + "number": 27, + "title": "feat(demo): 优化 demo 前端展示(合并 surponess_br)", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 3 + }, + { + "number": 26, + "title": "chore: move community-ops-sweep workflow to project root workflows/", + "status": "merged", + "merged_time": "2026-07-07", + "changed_files": 8 + } + ], + "doc_evolution": [ + { + "file": "README.md", + "last_date": "" + }, + { + "file": "README.zh-CN.md", + "last_date": "" + } + ], + "experiment_files": [ + "pr-test-file.txt" + ], + "innovation_points": [ + { + "description": "feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化", + "evidence": "PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15", + "category": "大规模重构/新特性" + }, + { + "description": "修改知识图谱构建方法", + "evidence": "PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理", + "evidence": "PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03", + "category": "大规模重构/新特性" + }, + { + "description": "新增skills", + "evidence": "PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12", + "category": "大规模重构/新特性" + }, + { + "description": "新增demo", + "evidence": "PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 实现 batch issue 批量操作命令", + "evidence": "PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01", + "category": "大规模重构/新特性" + }, + { + "description": "feat: 统一全链路科研分析页面 + Dockerfile 修复", + "evidence": "PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07", + "category": "大规模重构/新特性" + }, + { + "description": "release download 命令 新增 download 快捷命令、错误消息统一化", + "evidence": "PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30", + "category": "特性引入" + } + ], + "meta": { + "commit_count": 27, + "merged_pr_count": 23, + "doc_count": 2, + "experiment_file_count": 1 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_085135/lineage/report.md b/research-output/session_20260708_085135/lineage/report.md new file mode 100644 index 0000000..8e7ea14 --- /dev/null +++ b/research-output/session_20260708_085135/lineage/report.md @@ -0,0 +1,67 @@ +# 仓库级科研项目洞悉报告 — whale_hihihi/gitlink-cli + +> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析 + +## 一、基础信息 + +- **默认分支**: `main` +- **采样提交**: 27 条(默认分支,最多 10×100) +- **已合并 PR**: 23 个 +- **文档文件**: 2 个 +- **实验/评测文件**: 1 个 + +## 二、提交活跃度时间线 + +- 时间跨度: 2026-04-17 → 2026-05-17(共 8 个有提交的日期) +- 峰值: 2026-04-17 当日 10 次提交 + +## 三、分支地图 + +| 分支 | 提交数 | 最后活跃 | 是否默认 | +|------|:------:|----------|:--------:| +| `main` | 27 | 2026-05-17 | 是 | + +## 四、合并 PR 演进模式(高影响合并预览) + +| PR | 标题 | 改动文件 | 合并时间 | +|----|------|:--------:|----------| +| #2 | 本次实现:search +issues — 搜索 Issue 功能 | 4 | 2026-05-25 | +| #3 | 创建 webhook 领域 + 实现 `+update` | 2 | 2026-05-28 | +| #5 | release download 功能(新增) 在 release.go 的 Shortcuts() 中新增第 5 个快捷命令 download: 参数:- | 7 | 2026-05-29 | +| #4 | label 领域原来只有 3 个命令(list/create/delete),现在新增了 update 命令。 | 3 | 2026-05-29 | +| #6 | release download 命令 新增 download 快捷命令、错误消息统一化 | 8 | 2026-05-30 | +| #12 | feat: 实现 batch issue 批量操作命令 | 14 | 2026-06-01 | +| #8 | 文件描述符泄漏等修改 | 5 | 2026-06-01 | +| #7 | 代码片段管理功能 | 5 | 2026-06-01 | +| #14 | feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 | 31 | 2026-06-03 | +| #15 | 修改编译 | 9 | 2026-06-04 | + +## 五、创新/里程碑点 + +1. **[大规模重构/新特性]** feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化 + - 证据: PR #18 「feat: 合并 upstream/master 并对齐风格 — 行为修复 + i18n 化」 改动 151 文件,合并于 2026-06-15 +2. **[大规模重构/新特性]** 修改知识图谱构建方法 + - 证据: PR #31 「修改知识图谱构建方法」 改动 111 文件,合并于 2026-07-07 +3. **[大规模重构/新特性]** feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理 + - 证据: PR #14 「feat: 补全 21 个 Shortcut + 修正批量操作 + 文档/代码全面清理」 改动 31 文件,合并于 2026-06-03 +4. **[大规模重构/新特性]** 新增skills + - 证据: PR #16 「新增skills」 改动 26 文件,合并于 2026-06-12 +5. **[大规模重构/新特性]** 新增demo + - 证据: PR #24 「新增demo」 改动 19 文件,合并于 2026-07-06 +6. **[大规模重构/新特性]** feat: 实现 batch issue 批量操作命令 + - 证据: PR #12 「feat: 实现 batch issue 批量操作命令」 改动 14 文件,合并于 2026-06-01 +7. **[大规模重构/新特性]** feat: 统一全链路科研分析页面 + Dockerfile 修复 + - 证据: PR #29 「feat: 统一全链路科研分析页面 + Dockerfile 修复」 改动 13 文件,合并于 2026-07-07 +8. **[特性引入]** release download 命令 新增 download 快捷命令、错误消息统一化 + - 证据: PR #6 「release download 命令 新增 download 快捷命令、错误消息统一化」 改动 8 文件,合并于 2026-05-30 + +## 六、文档演进(docs/*) + +| 文档 | 近似最后日期 | +|------|--------------| +| README.md | — | +| README.zh-CN.md | — | + +## 七、实验/评测文件组织 + +- `pr-test-file.txt` diff --git a/research-output/session_20260708_085135/visual/report.md b/research-output/session_20260708_085135/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_085135/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_085135/visual/visual.html b/research-output/session_20260708_085135/visual/visual.html new file mode 100644 index 0000000..d5fbcba --- /dev/null +++ b/research-output/session_20260708_085135/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260708_085135/visual/visual.json b/research-output/session_20260708_085135/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_085135/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/research-output/session_20260708_085532/visual/report.md b/research-output/session_20260708_085532/visual/report.md new file mode 100644 index 0000000..8454f3f --- /dev/null +++ b/research-output/session_20260708_085532/visual/report.md @@ -0,0 +1,51 @@ +# 科研成果可视化沉淀报告 — whale_hihihi/gitlink-cli + +> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」 + +## 一、活跃度概览(最近 26 周) + +- 提交数: **27**(窗口内峰值 15 提交/周) +- 新增 Issue: **0**,新增 PR: **30** +- 贡献者: **20**,里程碑: **1** + +## 二、开发节奏(最近 8 周快照) + +| 周 | commits | issues | prs | +|----|---------|--------|-----| +| 2026-W21 | 0 | 0 | 0 | +| 2026-W22 | 0 | 0 | 6 | +| 2026-W23 | 0 | 0 | 9 | +| 2026-W24 | 0 | 0 | 1 | +| 2026-W25 | 0 | 0 | 2 | +| 2026-W26 | 0 | 0 | 2 | +| 2026-W27 | 0 | 0 | 2 | +| 2026-W28 | 0 | 0 | 8 | + +## 三、核心贡献者热力(贡献者 × 周提交数) + +| 贡献者 | 窗口内提交 | +|--------|-----------| +| `wbtiger` | 17 | +| `whale` | 0 | +| `wauxing` | 0 | +| `Surponess` | 0 | +| `wangyue789` | 1 | +| `tiger` | 0 | +| `muel` | 0 | +| `puygob236` | 0 | +| `wbavon` | 8 | +| `Mengz` | 0 | + +## 四、科研产物分类 + +- 论文/笔记 (paper): **0** +- 数据集 (dataset): **0** +- 模型 (model): **0** +- 基准 (benchmark): **0** + +## 五、抽取到的论文引用 + +_未在 README/提交信息中发现 arXiv 或 DOI 引用_ + + +_交互可视化见 visual.html(或原始数据 visual.json)_ diff --git a/research-output/session_20260708_085532/visual/visual.html b/research-output/session_20260708_085532/visual/visual.html new file mode 100644 index 0000000..2d20546 --- /dev/null +++ b/research-output/session_20260708_085532/visual/visual.html @@ -0,0 +1,7 @@ + + + +
+
+ + \ No newline at end of file diff --git a/research-output/session_20260708_085532/visual/visual.json b/research-output/session_20260708_085532/visual/visual.json new file mode 100644 index 0000000..f52e834 --- /dev/null +++ b/research-output/session_20260708_085532/visual/visual.json @@ -0,0 +1,532 @@ +{ + "scenario": "S6_research_visualization", + "repo": "whale_hihihi/gitlink-cli", + "weeks": 26, + "timeline": { + "labels": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "commits": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 15, + 0, + 1, + 0, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "issues": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prs": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 9, + 1, + 2, + 2, + 2, + 8 + ] + }, + "heatmap": { + "users": [ + "wbtiger", + "whale", + "wauxing", + "Surponess", + "wangyue789", + "tiger", + "muel", + "puygob236", + "wbavon", + "Mengz", + "baoerjun", + "yangsai01" + ], + "weeks": [ + "2026-W03", + "2026-W04", + "2026-W05", + "2026-W06", + "2026-W07", + "2026-W08", + "2026-W09", + "2026-W10", + "2026-W11", + "2026-W12", + "2026-W13", + "2026-W14", + "2026-W15", + "2026-W16", + "2026-W17", + "2026-W18", + "2026-W19", + "2026-W20", + "2026-W21", + "2026-W22", + "2026-W23", + "2026-W24", + "2026-W25", + "2026-W26", + "2026-W27", + "2026-W28" + ], + "matrix": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "languages": { + "Dockerfile": "0.1%", + "Go": "60.5%", + "HTML": "8.2%", + "JavaScript": "1.9%", + "Mermaid": "3.7%", + "Python": "22.4%", + "Shell": "3.2%" + }, + "milestones": [ + { + "title": "完成批量成员邀请", + "start": 1779321600.0, + "due": 1780531200.0 + } + ], + "paper_links": [], + "artifacts": [], + "artifact_summary": { + "paper": 0, + "dataset": 0, + "model": 0, + "benchmark": 0 + }, + "meta": { + "commit_count": 27, + "issue_count": 0, + "pr_count": 30, + "milestone_count": 1, + "contributor_count": 20 + } +} \ No newline at end of file diff --git a/scripts/research/README.md b/scripts/research/README.md new file mode 100644 index 0000000..8b1fdbe --- /dev/null +++ b/scripts/research/README.md @@ -0,0 +1,59 @@ +# scripts/research — 子赛题四·科研辅助算法层 + +本目录是子赛题四「应用 GitLink 辅助科研」的 **Python 工具代码**(赛题交付物之一)。 +采用 **Go 出数据 + Python 做算法** 的分工:所有原始数据经现有 gitlink-cli(25 个域)获取, +Python 负责知识图谱 / 协作匹配 / 可视化 / 复现性 / 报告等算法与产物生成。 + +## 文件 + +| 文件 | 场景 | 说明 | +|------|------|------| +| `gitlink_data.py` | 共享 | 调 gitlink-cli、解析 envelope、限速、分页、只读 health SQLite | +| `collect.py` | 共享 | 各命令的采集器 + 字段归一化(login/repo 全名等) | +| `lineage.py` | S1 | 仓库级科研项目洞悉:提交/分支/PR/文档/实验代码演进 + 创新点 | +| `graph_build.py` | S2 | 科研知识图谱(networkx):节点/边模型 + 主题词典 + mermaid/DOT | +| `repro.py` | S3 | 合规与复现性:license/密钥/依赖/复现清单 + 评分 | +| `match.py` | S4 | 科研协作智能匹配:学者画像 × 仓库缺口 TF-IDF + 余弦 | +| `report.py` | S5 | 科研进度智能跟踪与预警:周统计 + 里程碑 + 风险阈值 | +| `visual.py` | S6 | 科研成果可视化:plotly 时间线/热力/饼/甘特/论文关联 | +| `templates/*.j2` | 全部 | jinja2 中文报告模板 | +| `test_*.py` | — | 单元测试(`pytest scripts/research/`) | + +## 安装 + +```bash +pip install -r scripts/research/requirements.txt +``` + +## 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `GITLINK_CLI` | `gitlink-cli` | CLI 可执行路径;本地 Windows 开发可设 `./gitlink-cli.exe` | +| `GITLINK_CLI_INTERVAL` | `0.6` | CLI 调用最小间隔(秒),防 API 限流 | +| `GITLINK_HEALTH_DB` | `~/.agents/skills/gitlink-health/data/gitlink_health.db` | health SQLite 路径(图谱/匹配读历史协作数据) | + +## 数据来源 + +- **结构化数据**:`repo/issue/pr/search/user/org/milestone/file/license` 等域,输出统一 envelope `{ok,data,meta}`。 +- **提交历史**:Raw API `gitlink-cli api GET /{owner}/{repo}/commits`(shortcuts 无 repo +commits,路径已在 mindspore 仓库核验通过)。 +- **历史协作**:直接只读 `health` SQLite(`users/repos/issues/pulls/tags` 表)。 + +## 验证仓库 + +**主验证仓库:`mindspore-Ecosystem/mindspore`**(华为 MindSpore 深度学习框架镜像,真实科研级 AI 框架): +issues≈20346 / PR=9 / 贡献者=6 / star=31,协作数据充足,适合 S2/S4/S5;同时是 Python 研究代码仓库,适合 S1/S3/S6。 +S2 知识图谱为多仓库场景,按关键词 `search +repos` 跨仓库构建。 + +### 已核验响应形状(mindspore 实测,供各场景脚本参考) +- `repo +info` → `default_branch`/`issues_count`/`pull_requests_count`/`contributor_users_count`/`size`/`clone_url` +- `repo +contributors[]` → `login`/`name`/`contributions`/`contribution_perc`/`email` +- `issue +list[]` → 标题 `subject`、时间 `created_at`、`status`、`priority_name`、`milestone_name`、`author`、`number`/`project_issues_index` +- `pr +list[]` → 标题 `title`、创建时间 `pr_created_unix`(秒)、`status`(0=open/1=merged/2=closed)、`reviewers`、`index` +- `api GET /commits[]` → `sha`/`message`/`timestamp`/`author.login`/`committer.login` +- `repo +languages` → `{"Python":"99.7%", ...}` + +## 复现 + +每个场景配一个 `skills//examples/-workflow.sh`,串联 CLI → Python → 报告产物, +即赛题要求的「可复现执行脚本」。 diff --git a/scripts/research/collect.py b/scripts/research/collect.py new file mode 100644 index 0000000..43d2cb5 --- /dev/null +++ b/scripts/research/collect.py @@ -0,0 +1,308 @@ +"""collect.py — 子赛题四共享数据采集器。 + +每个采集器是对 `gitlink-cli +` 或 Raw API 的薄封装,返回归一化后的 +Python 对象(dict/list)。所有 GitLink 操作经 gitlink-cli(见 gitlink-shared 工具边界)。 + +形状说明:GitLink 不同端点字段差异较大,本模块只做“尽力归一”,把不确定字段原样透传; +具体字段解读留给各场景脚本(lineage/graph/match/...)并在其阶段用真实仓库验证后收敛。 +""" +from __future__ import annotations + +from typing import Any, Iterable + +import gitlink_data as gd + +# --------------------------------------------------------------------------- +# 归一化小工具 +# --------------------------------------------------------------------------- + +def as_str(v: Any) -> str: + if v is None: + return "" + return str(v) + + +def as_int(v: Any, default: int = 0) -> int: + try: + return int(v) + except (TypeError, ValueError): + return default + + +def as_float(v: Any, default: float = 0.0) -> float: + try: + return float(v) + except (TypeError, ValueError): + return default + + +def login_of(obj: dict) -> str: + """从用户/作者对象里尽量取出 login(GitLink 嵌套形式多变)。""" + if not isinstance(obj, dict): + return "" + for path_keys in (("login",), ("name",), ("username",), + ("author", "login"), ("user", "login"), + ("owner", "login"), ("author", "name")): + cur: Any = obj + ok = True + for k in path_keys: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + else: + ok = False + break + if ok and isinstance(cur, str) and cur: + return cur + return "" + + +def repo_fullname(project: dict) -> str: + """从 /projects 里的仓库对象取 'owner/identifier' 全名。""" + if not isinstance(project, dict): + return "" + identifier = project.get("identifier") or project.get("name") or project.get("repo_name") or "" + owner = login_of(project.get("author") or project.get("owner") or {}) or as_str(project.get("owner_login")) + if owner and identifier: + return f"{owner}/{identifier}" + return identifier + + +# --------------------------------------------------------------------------- +# 仓库级 +# --------------------------------------------------------------------------- + +def repo_info(owner: str, repo: str) -> dict: + return gd.run_data(["repo", "+info"], owner=owner, repo=repo) or {} + + +def readme(owner: str, repo: str, ref: str = "master") -> str: + data = gd.run_data(["repo", "+readme", "--ref", ref], owner=owner, repo=repo) + if isinstance(data, str): + return data + if isinstance(data, dict): + for k in ("content", "text", "readme", "markdown", "data"): + v = data.get(k) + if isinstance(v, str) and v: + return v + return "" + + +def tree(owner: str, repo: str, path: str = "", ref: str = "master") -> list: + flags = ["repo", "+tree", "--ref", ref] + if path: + flags += ["--path", path] + data = gd.run_data(flags, owner=owner, repo=repo) + return gd.first_list(data, ("entries", "trees", "files", "sub_entries")) + + +def languages(owner: str, repo: str) -> dict: + data = gd.run_data(["repo", "+languages"], owner=owner, repo=repo) + return data if isinstance(data, dict) else {} + + +def contributors(owner: str, repo: str, limit: int = 100) -> list: + """贡献者列表。`repo +contributors` 无分页 flag(见 shortcuts/repo/repo.go), + 故单次取全量;limit 仅为兼容保留、不传给 CLI。""" + data = gd.run_data(["repo", "+contributors"], owner=owner, repo=repo) + return gd.first_list(data, ("contributors", "list")) + + +# --------------------------------------------------------------------------- +# Issue / PR / 里程碑(分页) +# --------------------------------------------------------------------------- + +def issues(owner: str, repo: str, state: str = "all", max_pages: int = 10, + page_size: int = 50) -> list: + extra = ["--state", state] if state and state != "all" else [] + return gd.paginate("issue", "list", owner=owner, repo=repo, + page_size=page_size, max_pages=max_pages, extra_flags=extra) + + +def prs(owner: str, repo: str, state: str = "all", max_pages: int = 10, + page_size: int = 50) -> list: + extra = ["--state", state] if state and state != "all" else [] + # GitLink 把 PR 列表也放在 data["issues"] 键下(见 shortcuts/health/api.go)。 + return gd.paginate("pr", "list", owner=owner, repo=repo, + list_keys=("issues", "pulls", "list"), + page_size=page_size, max_pages=max_pages, extra_flags=extra) + + +def issues_all(owner: str, repo: str, max_pages: int = 10, + page_size: int = 50) -> list: + """全部 Issue(open + closed 合并去重)。 + + `issue +list` 默认只返 open;取 open/closed 两个状态并集按 id 去重, + 每条保留真实 status 字段。供 S5/S6 做全量统计。 + """ + seen: dict = {} + for state in ("open", "closed"): + for iss in issues(owner, repo, state=state, + max_pages=max_pages, page_size=page_size): + key = iss.get("id") or iss.get("index") + if key is not None and key not in seen: + seen[key] = iss + return list(seen.values()) + + +def prs_all(owner: str, repo: str, max_pages: int = 10, + page_size: int = 50) -> list: + """全部 PR(open + merged + closed 合并去重)。 + + GitLink 的 `pr +list` 状态过滤不可靠(不同仓库行为不一:有的默认只返 open, + 有的 --state 不生效返混合),故取三个状态并集按 index 去重,每条 PR 保留其 + 真实 status 字段('merged'/'open'/'closed')供上层分类。仿 health 采集法。 + """ + seen: dict = {} + for state in ("open", "merged", "closed"): + for pr in prs(owner, repo, state=state, + max_pages=max_pages, page_size=page_size): + key = pr.get("index") or pr.get("id") or pr.get("number") + if key is None: + continue + if key not in seen: + seen[key] = pr + return list(seen.values()) + + +def pr_detail(owner: str, repo: str, index: int) -> dict: + """单个 PR 详情:`pr +view --id `。 + + PR 列表 API 不返回文件改动数;详情接口返回 `files_count` / `commits_count`, + 用于 S1 创新点识别的「大规模重构」判据。 + """ + return gd.run_data(["pr", "+view", "--id", str(index)], owner=owner, repo=repo) or {} + + +def milestones(owner: str, repo: str, state: str = "all") -> list: + extra = ["--status", state] if state and state != "all" else [] + data = gd.run_data(["milestone", "+list", *extra, "--limit", "100"], + owner=owner, repo=repo) + return gd.first_list(data, ("milestones", "list")) + + +# --------------------------------------------------------------------------- +# 搜索 / 用户 +# --------------------------------------------------------------------------- + +def search_repos(keyword: str, limit: int = 20) -> list: + data = gd.run_data(["search", "+repos", "-k", keyword, "--limit", str(limit)]) + return gd.first_list(data, ("projects", "repos")) + + +def search_users(keyword: str, limit: int = 20) -> list: + data = gd.run_data(["search", "+users", "-k", keyword, "--limit", str(limit)]) + return gd.first_list(data, ("users", "list")) + + +# --------------------------------------------------------------------------- +# GitLink 官方「分类精选 / 探索」源(pinned=d)—— 热点追踪的主力数据源 +# GET /api/project_categories.json -> [{id,name}, ...](约 44 个领域) +# GET /api/projects.json?pinned=d&category_id=N&limit=M +# --------------------------------------------------------------------------- + +def categories() -> list: + """GitLink 全部领域分类(id + name,约 44 个)。公开、免鉴权。 + + 优先走 `explore +categories`;失败回退 Raw API。 + """ + data = gd.run_data(["explore", "+categories"]) + if isinstance(data, dict) and data.get("project_categories"): + return data["project_categories"] + data = gd.api("GET", "/project_categories") or {} + return data.get("project_categories") if isinstance(data, dict) else [] + + +def category_to_id(category: Any) -> str: + """分类名 → id(已是数字原样返回);查不到返回原值。""" + s = as_str(category) + if s.isdigit(): + return s + for c in categories(): + if as_str(c.get("name")) == s: + return as_str(c.get("id")) + return s + + +def pinned(category: Any, limit: int = 20) -> list: + """某分类下 GitLink 官方精选项目(pinned=d,curated)。 + + category 可传中文名(如 "深度学习")或 id(如 32)。每项含: + identifier / name / visits / praises_count / forked_count / time_ago / + author{login} / topics / language。owner 取自 author.login(见 repo_fullname)。 + """ + data = gd.run_data(["explore", "+pinned", "--category", str(category), + "--limit", str(limit)]) + projects = gd.first_list(data, ("projects", "repos")) if data is not None else [] + if not projects: # 回退 Raw API(旧二进制无 explore 域时) + cid = category_to_id(category) + data = gd.api("GET", "/projects", query=f"pinned=d&category_id={cid}&limit={limit}") + projects = gd.first_list(data, ("projects", "repos")) + return projects or [] + + +def user_info(login: str) -> dict: + return gd.run_data(["user", "+info", "--login", login]) or {} + + +def user_repos(login: str, limit: int = 20) -> list: + """某用户的公开仓库列表(`repo +list --user --category all`)。 + + 必须显式传 `--category all`:`repo +list` 的 category 默认是 `manage` + ("我管理的"),用来列别人的仓库时会 404/空。返回 data.projects, + 每项含 identifier(仓库名)/language({id,name})/description 等。 + """ + data = gd.run_data(["repo", "+list", "--user", login, + "--category", "all", "--limit", str(limit)]) + return gd.first_list(data, ("projects", "repos")) + + +# --------------------------------------------------------------------------- +# 提交历史(Raw API:shortcuts 未提供 repo +commits) +# --------------------------------------------------------------------------- + +def commits(owner: str, repo: str, ref: str = "master", max_pages: int = 5, + page_size: int = 100) -> list: + """通过 `api GET /{owner}/{repo}/commits` 分页取提交。 + + 注:该端点确切路径/分页参数需在 Phase 0c 用真实仓库核验;若不通, + 退化方案见 doc 注释(按分支取或用 compare 端点)。 + """ + out: list = [] + for page in range(1, max_pages + 1): + data = gd.api("GET", f"/{owner}/{repo}/commits", + query=f"sha={ref}&page={page}&limit={page_size}", + owner=owner, repo=repo) + items = gd.first_list(data, ("commits", "list")) + if not items: + break + out.extend(items) + if len(items) < page_size: + break + return out + + +def file_text(owner: str, repo: str, path: str, ref: str = "master") -> str: + """读取仓库内某文件的文本内容(LICENSE / CI 配置 / lockfile 等)。 + + `file +get`(sub_entries)返回形如 {"entries": {"content": "...", "commit": {...}}}。 + """ + data = gd.run_data(["file", "+get", "--path", path, "--ref", ref], + owner=owner, repo=repo) + if isinstance(data, str): + return data + if isinstance(data, dict): + # 顶层直接带内容 + for k in ("content", "text", "data"): + v = data.get(k) + if isinstance(v, str) and v: + return v + entries = data.get("entries") + # 形式一:entries 是 dict,内含 content 键(GitLink 实测) + if isinstance(entries, dict): + v = entries.get("content") or entries.get("text") + if isinstance(v, str) and v: + return v + # 形式二:entries 是 list[dict] + elif isinstance(entries, list) and entries and isinstance(entries[0], dict): + return as_str(entries[0].get("content") or entries[0].get("text")) + return "" diff --git a/scripts/research/gitlink_data.py b/scripts/research/gitlink_data.py new file mode 100644 index 0000000..a09b3cf --- /dev/null +++ b/scripts/research/gitlink_data.py @@ -0,0 +1,196 @@ +"""gitlink_data.py — 子赛题四数据访问共享层。 + +职责: + 1. 以子进程方式调用 gitlink-cli,解析统一 envelope({ok,data,error,meta})。 + 2. 内置限速,避免触发 GitLink API 限流(参考 shortcuts/health 的 ~1.7 call/s)。 + 3. 列表分页累积。 + 4. 直接只读访问 health SQLite(shortcuts/health/schema.sql),供 S2 图谱 / S4 匹配取历史协作数据。 + +设计原则:本层只“取数 + 解析”,不做任何业务算法;算法在各场景脚本中实现。 +所有 GitLink 操作一律经 gitlink-cli,绝不用 gh/glab(见 skills/gitlink-shared/SKILL.md 工具边界)。 +""" +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Iterable + +# --------------------------------------------------------------------------- +# 配置 +# --------------------------------------------------------------------------- + +# CLI 可执行路径:默认走 PATH 上的 gitlink-cli;本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe +def cli_path() -> str: + return os.environ.get("GITLINK_CLI", "gitlink-cli") + + +# 最小调用间隔(秒),限制对 GitLink API 的请求频率。 +MIN_INTERVAL = float(os.environ.get("GITLINK_CLI_INTERVAL", "0.6")) + +_last_call = 0.0 + + +def _throttle() -> None: + """简单的全局令牌限速:两次调用间至少间隔 MIN_INTERVAL 秒。""" + global _last_call + now = time.time() + wait = MIN_INTERVAL - (now - _last_call) + if wait > 0: + time.sleep(wait) + _last_call = time.time() + + +def _warn(msg: str) -> None: + sys.stderr.write(f"[gitlink] {msg}\n") + + +# --------------------------------------------------------------------------- +# 核心:调用 CLI +# --------------------------------------------------------------------------- + +def run(args: Iterable[str], owner: str | None = None, repo: str | None = None, + fmt: str = "json") -> dict[str, Any]: + """调用 `gitlink-cli [global flags] args... --format fmt`,返回解析后的 envelope dict。 + + 失败时返回 {"ok": False, "error": {...}},不抛异常,便于调用方容错。 + """ + cmd = [cli_path()] + if owner: + cmd += ["--owner", owner] + if repo: + cmd += ["--repo", repo] + cmd += list(args) + if fmt: + cmd += ["--format", fmt] + + _throttle() + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except FileNotFoundError: + _warn(f"gitlink-cli 未找到(cmd={cmd[0]}),请设置 GITLINK_CLI 环境变量") + return {"ok": False, "error": {"message": f"gitlink-cli not found: {cmd[0]}"}} + except subprocess.TimeoutExpired: + _warn(f"调用超时:{' '.join(cmd)}") + return {"ok": False, "error": {"message": "timeout"}} + + if proc.returncode != 0: + _warn(f"exit={proc.returncode} cmd={' '.join(cmd)}\n{proc.stderr.strip()}") + return {"ok": False, "error": {"message": (proc.stderr or proc.stdout).strip() or f"exit {proc.returncode}"}} + + out = proc.stdout.strip() + if not out: + return {"ok": True, "data": None} + try: + return json.loads(out) + except json.JSONDecodeError: + # 非 JSON(表格/原始文本),原样包裹返回 + return {"ok": True, "data": out} + + +def run_data(args: Iterable[str], owner: str | None = None, repo: str | None = None, + fmt: str = "json") -> Any: + """run() 的便捷封装:成功返回 data 字段,失败返回 None 并告警。""" + env = run(args, owner=owner, repo=repo, fmt=fmt) + if not env.get("ok"): + msg = (env.get("error") or {}).get("message", "unknown error") + _warn(f"{list(args)} -> {msg}") + return None + return env.get("data") + + +def api(method: str, path: str, query: str | None = None, + owner: str | None = None, repo: str | None = None) -> Any: + """调用 Raw API:`gitlink-cli api METHOD PATH --query ... --format json`。 + + 用于 shortcuts 未覆盖的端点(如仓库提交历史 GET /{owner}/{repo}/commits)。 + """ + args = ["api", method, path] + if query: + args += ["--query", query] + return run_data(args, owner=owner, repo=repo) + + +# --------------------------------------------------------------------------- +# 分页 / 形状工具 +# --------------------------------------------------------------------------- + +# GitLink 各端点把列表放在不同键下;按优先级尝试这些键。 +DEFAULT_LIST_KEYS = ( + "projects", "repos", "issues", "pulls", "users", "list", + "contributors", "entries", "commits", "milestones", "tags", +) + + +def first_list(data: Any, keys: Iterable[str] = DEFAULT_LIST_KEYS) -> list: + """从 envelope.data 里稳健地取出列表:data 本身是列表则直接返回,否则尝试已知键。""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for k in keys: + v = data.get(k) + if isinstance(v, list): + return v + # 兜底:唯一一个 list 值 + list_vals = [v for v in data.values() if isinstance(v, list)] + if len(list_vals) == 1: + return list_vals[0] + return [] + + +def total_count(data: Any) -> int | None: + if isinstance(data, dict): + for k in ("total_count", "totalCount", "count", "total"): + if isinstance(data.get(k), (int, float)): + return int(data[k]) + return None + + +def paginate(domain: str, verb: str, list_keys=DEFAULT_LIST_KEYS, + page_size: int = 50, max_pages: int = 20, + owner: str | None = None, repo: str | None = None, + extra_flags: Iterable[str] = ()) -> list: + """对一个 `gitlink-cli +` 列表命令做多页累积。 + + 依赖命令支持 --page/--limit 两个 flag(repo/issue/pr/search/milestone 等均支持)。 + """ + collected: list = [] + for page in range(1, max_pages + 1): + flags = [f"+{verb}", "--page", str(page), "--limit", str(page_size), *extra_flags] + data = run_data([domain, *flags], owner=owner, repo=repo) + if data is None: + break + items = first_list(data, list_keys) + if not items: + break + collected.extend(items) + total = total_count(data) + if total is not None and len(collected) >= total: + break + if len(items) < page_size: + break + return collected + + +# --------------------------------------------------------------------------- +# health SQLite 只读访问 +# --------------------------------------------------------------------------- + +def health_db_path() -> str: + return os.environ.get( + "GITLINK_HEALTH_DB", + str(Path.home() / ".agents" / "skills" / "gitlink-health" / "data" / "gitlink_health.db"), + ) + + +def open_health_db() -> sqlite3.Connection | None: + """以只读方式打开 health SQLite;文件不存在则返回 None(调用方退化为纯 API 取数)。""" + p = health_db_path() + if not Path(p).exists(): + return None + # mode=ro 防止误写;modernc/sqlite 已开 WAL,Python 只读并发安全。 + return sqlite3.connect(f"file:{p}?mode=ro", uri=True) diff --git a/scripts/research/graph_build.py b/scripts/research/graph_build.py new file mode 100644 index 0000000..5715c62 --- /dev/null +++ b/scripts/research/graph_build.py @@ -0,0 +1,658 @@ +"""graph_build.py — S2 科研热点追踪与知识图谱。 + +输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库(search +repos), +对每个候选仓库取 repo_info / contributors / languages / README,再用 +networkx.MultiDiGraph 构建一张「仓库—学者—主题」科研知识图谱: + + 节点 + - repo : id = repo:owner/name (props: language/stars/forks/desc) + - scholar : id = scholar:login (来自 contributors,过滤 bot/i-robot) + - topic : id = topic:x (由 topics.py 词典抽取) + 边 + - contributes_to : scholar → repo (weight = contribution_perc 解析为 0~1) + - owns : scholar → repo (当 author.login == contributor login) + - covers_topic : repo → topic (weight = 出现次数 / max) + - collaborates_with : scholar → scholar (共享同一 repo) + - related_to : topic ↔ topic (在同一 repo 共现) + +「取数」与「建图」严格分离:build_graph() 只接收已经取好的 Python 数据结构, +便于离线单测(不联网、不调 gitlink-cli)。collect() 负责在线取数。 + +数据全部经 gitlink-cli 获取(search +repos / repo +info / repo +contributors / +repo +languages / repo +readme)。 + +用法: + python graph_build.py --keywords "deep learning,nlp" --repos-limit 20 --out ./out + python graph_build.py --keywords "knowledge graph" # 仅打印 JSON +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import math +import os +import sys +import time +from collections import Counter, defaultdict +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 +import topics as T # noqa: E402 + +import networkx as nx # noqa: E402 + + +# --------------------------------------------------------------------------- +# 小工具 +# --------------------------------------------------------------------------- + +BOT_LOGIN_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot") + + +def is_bot(login: str) -> bool: + """识别明显机器人账号(不作为学者节点)。""" + if not login: + return True + low = login.lower() + return any(h in low for h in BOT_LOGIN_HINTS) + + +def parse_ratio(v: Any) -> float: + """把 '1.18%' / '0.2' / 0.2 等统一解析为 0~1 比例(与 match.py 一致)。""" + if v is None: + return 0.0 + s = str(v).strip() + pct = s.endswith("%") + if pct: + s = s[:-1] + try: + f = float(s) + except ValueError: + return 0.0 + return f / 100.0 if (pct or f > 1.0) else f + + +def _topic_heat(descriptions: list[str], top: int = 10) -> list[dict]: + """对全部仓库 description 跑 topics.topic_counter,取 top 热度榜。""" + cnt = T.topic_counter(descriptions) + return [{"topic": t, "count": n} for t, n in cnt.most_common(top)] + + +# --------------------------------------------------------------------------- +# 热点追踪:飙升项目 + 活跃讨论(快照代理;真·增长率需定时轮询存历史) +# --------------------------------------------------------------------------- + +def _to_epoch(v: Any) -> float: + """把 GitLink 时间(ISO 字符串或整数秒)解析为 epoch 秒,失败返回 0.0。""" + if v is None: + return 0.0 + if isinstance(v, (int, float)): + return float(v) / 1000.0 if v > 1e12 else float(v) + s = str(v).strip() + if not s: + return 0.0 + if s.isdigit(): + f = float(s) + return f / 1000.0 if f > 1e12 else f + iso = s.replace("Z", "+00:00") + try: + return _dt.datetime.fromisoformat(iso).timestamp() + except (ValueError, TypeError): + import re + m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s) + if m: + try: + return _dt.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).timestamp() + except ValueError: + return 0.0 + return 0.0 + + +def _iso_day(epoch: float) -> str: + if not epoch: + return "" + try: + return _dt.datetime.utcfromtimestamp(epoch).strftime("%Y-%m-%d") + except (OSError, ValueError, OverflowError): + return "" + + +def compute_trending(repos: list[dict], top: int = 15) -> list[dict]: + """对搜索到的仓库按「热度分数」排序,作为飙升/热门项目代理。 + + 分数 = star + fork×2 + 近期更新加成;并给「日均增星(velocity)」作为 + day-1 可用的趋势代理(真·增长率需定时轮询存历史快照,见 trends 表规划)。 + """ + now = time.time() + scored: list[dict] = [] + for r in repos: + fullname = r.get("fullname") or c.repo_fullname(r) + if not fullname: + continue + stars = c.as_int(r.get("praises_count")) + forks = c.as_int(r.get("forked_count")) + updated = _to_epoch(r.get("updated_at") or r.get("time") + or r.get("updated_on") or r.get("created_at")) + age_days = max(1.0, (now - updated) / 86400.0) if updated else 99999.0 + recency = max(0.0, 60.0 - age_days) # 60 天内更新有加成 + score = stars + forks * 2 + recency + velocity = round(stars / age_days, 3) if age_days < 99990 else 0.0 + lang_obj = r.get("language") + lang = lang_obj.get("name") if isinstance(lang_obj, dict) else c.as_str(lang_obj) + scored.append({ + "repo": fullname, + "description": c.as_str(r.get("description"))[:140], + "language": lang or "", + "stars": stars, + "forks": forks, + "updated": _iso_day(updated), + "velocity": velocity, + "score": round(score, 1), + }) + scored.sort(key=lambda x: -x["score"]) + return scored[:top] + + +def compute_active(issues_map: dict[str, list], prs_map: dict[str, list], + top: int = 12) -> list[dict]: + """跨仓库取评论/日志最多的 Issue / PR,作为「活跃讨论」信号。""" + items: list[dict] = [] + for fullname, issues in issues_map.items(): + for iss in issues or []: + if not isinstance(iss, dict): + continue + jc = c.as_int(iss.get("journals_count") or iss.get("comments_count")) + items.append({ + "repo": fullname, "type": "issue", + "number": iss.get("index") or iss.get("number") or iss.get("id"), + "title": c.as_str(iss.get("subject") or iss.get("title"))[:120], + "comments": jc, + "state": c.as_str(iss.get("status") or iss.get("issue_status") or "open"), + }) + for fullname, prs in prs_map.items(): + for pr in prs or []: + if not isinstance(pr, dict): + continue + jc = c.as_int(pr.get("journals_count") or pr.get("comments_count")) + items.append({ + "repo": fullname, "type": "pr", + "number": pr.get("index") or pr.get("number") or pr.get("id"), + "title": c.as_str(pr.get("title"))[:120], + "comments": jc, + "state": c.as_str(pr.get("status") or "open"), + }) + items.sort(key=lambda x: -x["comments"]) + # 优先有评论的;不足则按已有顺序补齐 + commented = [it for it in items if it["comments"] > 0] + return (commented or items)[:top] + + +# --------------------------------------------------------------------------- +# 建图(纯函数:不联网,只吃已取数据,便于单测) +# --------------------------------------------------------------------------- + +def build_graph(repos: list[dict], + contributors_map: dict[str, list[dict]], + languages_map: dict[str, dict], + readmes: dict[str, str], + keywords: list[str] | None = None) -> dict[str, Any]: + """构建科研知识图谱。 + + 参数(全部为「已取好」的 Python 数据,不联网): + repos : list[dict],每个元素至少含 fullname 与 repo_info 字段 + (identifier, author.login, description, language.name, + praises_count, forked_count)。 + contributors_map : {fullname: [contributor, ...]},contributor 至少含 + login + contribution_perc。 + languages_map : {fullname: {"Python": "99.7%", ...}}。 + readmes : {fullname: readme 文本(已截断到前 4000 字符)}。 + + 返回结果 dict(与 graph.json 结构一致)。 + """ + G = nx.MultiDiGraph() + + repo_nodes: dict[str, dict] = {} + scholar_repos: dict[str, set[str]] = defaultdict(set) + repo_topics: dict[str, Counter] = {} + descriptions: list[str] = [] + max_topic_count = 1 # 用于 covers_topic 归一化(防除零) + + # ---- 1) 仓库 + 主题节点 ---- + for r in repos: + fullname = r.get("fullname") or c.repo_fullname(r) + if not fullname: + continue + desc = c.as_str(r.get("description")) + lang_name = "" + lang_obj = r.get("language") + if isinstance(lang_obj, dict): + lang_name = c.as_str(lang_obj.get("name")) + stars = c.as_int(r.get("praises_count")) + forks = c.as_int(r.get("forked_count")) + readme = c.as_str(readmes.get(fullname))[:4000] + descriptions.append(desc) + + repo_id = f"repo:{fullname}" + props = { + "language": lang_name, + "stars": stars, + "forks": forks, + "description": desc, + "readme_head": readme[:200], + } + G.add_node(repo_id, type="repo", label=fullname, **props) + repo_nodes[repo_id] = {"label": fullname, **props} + + # 抽取主题(description + readme) + tps = T.extract_topics(desc + " " + readme) + cnt: Counter = Counter() + for tp in tps: + cnt[tp] += 1 + repo_topics[repo_id] = cnt + if cnt: + max_topic_count = max(max_topic_count, max(cnt.values())) + + # ---- 2) 主题节点 + covers_topic 边 ---- + topic_repos: dict[str, set[str]] = defaultdict(set) + repo_topic_pairs: dict[str, list[str]] = {} # repo_id -> [topic_id] + for repo_id, cnt in repo_topics.items(): + pairs: list[str] = [] + for tp, n in cnt.items(): + topic_id = f"topic:{tp}" + if topic_id not in G: + G.add_node(topic_id, type="topic", label=tp, count=0, + language="", stars=0, forks=0, description="") + # 累计该主题被多少仓库覆盖 + G.nodes[topic_id]["count"] += 1 + weight = n / max_topic_count if max_topic_count else 0.0 + G.add_edge(repo_id, topic_id, type="covers_topic", + weight=round(weight, 4)) + topic_repos[tp].add(repo_id) + pairs.append(topic_id) + repo_topic_pairs[repo_id] = pairs + + # ---- 3) 学者节点 + contributes_to / owns ---- + for fullname, contribs in contributors_map.items(): + repo_id = f"repo:{fullname}" + if repo_id not in G: + continue + owner_login = "" + # 从 repos 列表里取该仓库 author.login(判定 owns) + for r in repos: + if (r.get("fullname") or c.repo_fullname(r)) == fullname: + owner_login = c.login_of(r.get("author") or {}) + break + for contrib in contribs: + login = c.login_of(contrib) + if is_bot(login): + continue + scholar_id = f"scholar:{login}" + if scholar_id not in G: + G.add_node(scholar_id, type="scholar", label=login, + language="", stars=0, forks=0, description="") + weight = parse_ratio(contrib.get("contribution_perc")) + G.add_edge(scholar_id, repo_id, type="contributes_to", + weight=round(weight, 4)) + if login == owner_login: + G.add_edge(scholar_id, repo_id, type="owns", weight=1.0) + scholar_repos[login].add(repo_id) + + # ---- 4) collaborates_with(共享同一 repo 的两两学者)---- + for contribs in contributors_map.values(): + logins = [c.login_of(x) for x in contribs if not is_bot(c.login_of(x))] + logins = sorted(set(logins)) + if len(logins) < 2: + continue + for i in range(len(logins)): + for j in range(i + 1, len(logins)): + a = f"scholar:{logins[i]}" + b = f"scholar:{logins[j]}" + # 双向(无向语义;MultiDiGraph 用两条边近似) + G.add_edge(a, b, type="collaborates_with", weight=1.0) + G.add_edge(b, a, type="collaborates_with", weight=1.0) + + # ---- 5) related_to(同一 repo 内共现的两两主题)---- + for repo_id, tids in repo_topic_pairs.items(): + for i in range(len(tids)): + for j in range(i + 1, len(tids)): + a, b = tids[i], tids[j] + G.add_edge(a, b, type="related_to", weight=1.0) + G.add_edge(b, a, type="related_to", weight=1.0) + + # ---- 6) 导出 ---- + nodes_out = [] + for nid, attrs in G.nodes(data=True): + nodes_out.append({ + "id": nid, + "type": attrs.get("type", ""), + "label": attrs.get("label", nid), + "props": {k: v for k, v in attrs.items() + if k not in ("type", "label")}, + }) + edges_out = [] + for u, v, attrs in G.edges(data=True): + edges_out.append({ + "source": u, + "target": v, + "type": attrs.get("type", ""), + "weight": attrs.get("weight", 1.0), + }) + + # 核心学者:按出现 repo 数排序 + core_scholars = sorted( + ({"login": lg, "repo_count": len(rs)} for lg, rs in scholar_repos.items()), + key=lambda x: (-x["repo_count"], x["login"]), + )[:15] + + # 核心团队:仅统计「组织」类型(非个人 User)的仓库拥有者,按拥有仓库数排序。 + # (个人账号不算团队;author.type 区分 User / Organization) + owner_count: dict[str, int] = {} + owner_is_org: dict[str, bool] = {} + for r in repos: + author = r.get("author") or {} + lg = c.login_of(author) + if not lg: + continue + owner_count[lg] = owner_count.get(lg, 0) + 1 + tp = str(author.get("type", "")).lower() + if tp and tp not in ("user", ""): + owner_is_org[lg] = True + owner_logins = sorted( + [{"login": lg, "repo_count": cnt, "type": "organization"} + for lg, cnt in owner_count.items() if owner_is_org.get(lg)], + key=lambda x: (-x["repo_count"], x["login"]), + ) + + # 主题热度:取所有 description 的 top10(含图谱里实际命中的 count) + heat = _topic_heat(descriptions, top=10) + + return { + "scenario": "S2_research_knowledge_graph", + "keywords": list(keywords or []), + "nodes": nodes_out, + "edges": edges_out, + "core_scholars": core_scholars, + "core_teams": owner_logins, + "topic_heat": heat, + "meta": { + "keywords": list(keywords or []), + "repo_count": len(repo_nodes), + "node_count": G.number_of_nodes(), + "edge_count": G.number_of_edges(), + "scholar_count": sum(1 for n in nodes_out if n["type"] == "scholar"), + "topic_count": sum(1 for n in nodes_out if n["type"] == "topic"), + }, + } + + +# --------------------------------------------------------------------------- +# 取数(在线:调 gitlink-cli) +# --------------------------------------------------------------------------- + +def collect(keywords: list[str], repos_limit: int = 20) -> dict[str, Any]: + """按关键词搜索仓库并取其 info/contributors/languages/readme,返回原始数据。 + + 与 build_graph() 解耦:本函数可被替换为 mock(单测里直接构造数据喂 build_graph)。 + """ + seen: dict[str, dict] = {} # fullname -> 归一化的 repo dict + for kw in keywords: + for r in c.search_repos(kw, limit=repos_limit): + fullname = c.repo_fullname(r) + if not fullname or fullname in seen: + continue + seen[fullname] = _normalize_search_hit(r, fullname) + if len(seen) >= repos_limit: + break + + repos = list(seen.values())[:repos_limit] + contributors_map: dict[str, list[dict]] = {} + languages_map: dict[str, dict] = {} + readmes: dict[str, str] = {} + issues_map: dict[str, list[dict]] = {} + prs_map: dict[str, list[dict]] = {} + for r in repos: + fullname = r["fullname"] + owner, _, name = fullname.partition("/") + # 用仓库完整 info 覆盖搜索结果的稀疏字段 + info = c.repo_info(owner, name) + if info: + r["description"] = c.as_str(info.get("description")) or r.get("description", "") + r["praises_count"] = c.as_int(info.get("praises_count") or info.get("watchers_count")) + r["forked_count"] = c.as_int(info.get("forked_count")) + # 更新时间(飙升/热度排序用;GitLink 字段名兜底多个) + r["updated_at"] = (info.get("updated_at") or info.get("time") + or info.get("updated_on") or r.get("updated_at")) + if info.get("language") and isinstance(info["language"], dict): + r["language"] = info["language"] + contributors_map[fullname] = c.contributors(owner, name, limit=100) + languages_map[fullname] = c.languages(owner, name) + readmes[fullname] = c.readme(owner, name)[:4000] + # 活跃讨论:开放的 Issue / PR(评论多的=热讨论) + issues_map[fullname] = c.issues(owner, name, state="open", max_pages=1, page_size=50) + prs_map[fullname] = c.prs(owner, name, state="open", max_pages=1, page_size=50) + return {"repos": repos, "contributors_map": contributors_map, + "languages_map": languages_map, "readmes": readmes, + "issues_map": issues_map, "prs_map": prs_map} + + +def _normalize_search_hit(r: dict, fullname: str) -> dict: + """把 search_repos 返回项归一化为 build_graph 期望的形状。""" + lang_obj = r.get("language") + if not isinstance(lang_obj, dict): + lang_obj = {"name": c.as_str(lang_obj)} + return { + "fullname": fullname, + "identifier": r.get("identifier", fullname.split("/")[-1]), + "author": r.get("author") or {}, + "description": c.as_str(r.get("description")), + "language": lang_obj, + "praises_count": c.as_int(r.get("praises_count")), + "forked_count": c.as_int(r.get("forked_count")), + "forked_from_project_id": r.get("forked_from_project_id"), + } + + +# --------------------------------------------------------------------------- +# 渲染:Mermaid / DOT / Markdown 报告 +# --------------------------------------------------------------------------- + +_NODE_LIMIT = 40 # 防止 Mermaid 爆炸 + +_NODE_STYLE = { + "repo": ("repoNode", "#4C78A8"), + "scholar": ("scholarNode", "#F58518"), + "topic": ("topicNode", "#54A24B"), +} + + +def _safe_id(nid: str) -> str: + """Mermaid/DOT 节点 id 用安全字符(去冒号斜杠)。""" + return nid.replace(":", "_").replace("/", "_").replace("-", "_") + + +def render_mermaid(result: dict[str, Any], node_limit: int = _NODE_LIMIT) -> str: + """渲染前 ~40 节点的 Mermaid graph TD(带 classDef 着色)。""" + lines = ["```mermaid", "graph TD"] + # 类定义 + for t, (cls, color) in _NODE_STYLE.items(): + lines.append(f" classDef {cls} fill:{color},stroke:#333,color:#fff;") + + nodes = result.get("nodes", []) + edges = result.get("edges", []) + # 取前 node_limit 个节点(repo 优先,再 scholar,再 topic) + type_order = {"repo": 0, "scholar": 1, "topic": 2} + ordered = sorted(nodes, key=lambda n: (type_order.get(n["type"], 9), n["id"])) + picked = ordered[:node_limit] + picked_ids = {n["id"] for n in picked} + + label_map: dict[str, str] = {} + for n in picked: + sid = _safe_id(n["id"]) + label = n["label"].replace('"', "'") + lines.append(f' {sid}["{label}"]') + label_map[n["id"]] = sid + cls = _NODE_STYLE.get(n["type"], ("", ""))[0] + if cls: + lines.append(f" class {sid} {cls};") + + # 只画两端都在 picked 内的边;去重(同源同目标同类只画一条) + seen_edge: set[tuple] = set() + for e in edges: + if e["source"] not in picked_ids or e["target"] not in picked_ids: + continue + key = (e["source"], e["target"], e["type"]) + if key in seen_edge: + continue + seen_edge.add(key) + a = label_map[e["source"]] + b = label_map[e["target"]] + w = e.get("weight", 1.0) + et = e["type"] + # 不同边类型用不同箭头标签 + lines.append(f' {a} -- "{et}({w:.2f})" --> {b}') + + lines.append("```") + return "\n".join(lines) + + +def render_dot(result: dict[str, Any], node_limit: int = _NODE_LIMIT) -> str: + """渲染 Graphviz DOT 字符串(带节点着色)。""" + lines = ["digraph G {", ' rankdir=LR;', + ' graph [fontname="Helvetica"];', + ' node [fontname="Helvetica", style="filled"];', + ' edge [fontname="Helvetica"];'] + type_order = {"repo": 0, "scholar": 1, "topic": 2} + nodes = result.get("nodes", []) + edges = result.get("edges", []) + ordered = sorted(nodes, key=lambda n: (type_order.get(n["type"], 9), n["id"])) + picked = ordered[:node_limit] + picked_ids = {n["id"] for n in picked} + label_map: dict[str, str] = {} + for n in picked: + sid = _safe_id(n["id"]) + label = n["label"].replace('"', "'") + color = _NODE_STYLE.get(n["type"], ("", "#CCCCCC"))[1] + lines.append(f' {sid} [label="{label}", fillcolor="{color}"];') + label_map[n["id"]] = sid + for e in edges: + if e["source"] not in picked_ids or e["target"] not in picked_ids: + continue + a = label_map[e["source"]] + b = label_map[e["target"]] + lines.append(f' {a} -> {b} [label="{e["type"]}"];') + lines.append("}") + return "\n".join(lines) + + +def render_report(result: dict[str, Any]) -> str: + meta = result["meta"] + heat = result.get("topic_heat", []) + scholars = result.get("core_scholars", []) + teams = result.get("core_teams", []) + lines = [ + "# 科研热点追踪与知识图谱报告\n", + f"> 场景 S2 · 子赛题四「应用 GitLink 辅助科研」\n", + f"**关键词**: {', '.join(result.get('keywords') or []) or '—'}\n", + "## 一、图谱概览\n", + f"- 仓库节点: **{meta['repo_count']}**", + f"- 学者节点: **{meta['scholar_count']}**", + f"- 主题节点: **{meta['topic_count']}**", + f"- 节点总数: **{meta['node_count']}**", + f"- 边总数: **{meta['edge_count']}**\n", + "## 二、主题热度榜(基于全部仓库 description)\n", + "| 排名 | 主题 | 覆盖仓库数 |", + "|------|------|-----------|", + ] + if heat: + for i, h in enumerate(heat, 1): + lines.append(f"| {i} | `{h['topic']}` | {h['count']} |") + else: + lines.append("| — | (未识别到明确主题) | — |") + lines += ["\n## 三、核心学者(按出现仓库数排序)\n", + "| 排名 | 学者 | 关联仓库数 |", + "|------|------|-----------|"] + if scholars: + for i, s in enumerate(scholars[:10], 1): + lines.append(f"| {i} | `{s['login']}` | {s['repo_count']} |") + else: + lines.append("| — | (未识别到学者) | — |") + lines += [f"\n## 四、核心团队(组织型仓库拥有者)\n"] + if teams: + lines += ["| 团队/组织 | 拥有仓库数 |", "|-----------|-----------|"] + for t in teams: + if isinstance(t, dict): + lines.append(f"| `{t.get('login')}` | {t.get('repo_count', 0)} |") + else: + lines.append(f"| `{t}` | — |") + else: + lines.append("(该批仓库均由个人账号拥有,无组织型团队)") + # 飙升/热门项目(热度分数排序;日均增星 velocity 作趋势代理) + trending = result.get("trending_repos", []) + lines += ["\n## 五、热门 / 飙升项目(热度排序)\n", + "| 仓库 | 语言 | ★ | ⑂ | 日均★ | 最近更新 |", + "|------|------|---:|---:|---:|----------|"] + if trending: + for t in trending[:10]: + lines.append(f"| `{t['repo']}` | {t['language'] or '—'} | {t['stars']} | " + f"{t['forks']} | {t['velocity']} | {t['updated'] or '—'} |") + else: + lines.append("| — | (未取到仓库) | | | | |") + + # 活跃讨论 + active = result.get("active_discussions", []) + lines += ["\n## 六、活跃讨论(评论最多的 Issue / PR)\n", + "# | 类型 | 仓库 | 标题 | 评论 |", "|-|------|------|------|---:|"] + if active: + for i, a in enumerate(active[:10], 1): + lines.append(f"| {i} | {a['type']} | `{a['repo']}` | {a['title'][:50]} | {a['comments']} |") + else: + lines.append("| — | | | (暂无明显热讨论) | |") + + lines.append("\n_配套产物:graph.json(结构化)+ graph.mmd(Mermaid)+ " + "graph.dot(Graphviz DOT)_\n") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S2 科研热点追踪与知识图谱") + ap.add_argument("--keywords", required=True, + help='逗号分隔的关键词,如 "deep learning,nlp"') + ap.add_argument("--repos-limit", type=int, default=20, + help="每关键词搜索后去重取 top N 仓库(默认 20)") + ap.add_argument("--out", help="输出目录(写 graph.json/report.md/graph.mmd/graph.dot);" + "省略则打印 JSON") + args = ap.parse_args() + + keywords = [k.strip() for k in args.keywords.split(",") if k.strip()] + data = collect(keywords, repos_limit=args.repos_limit) + result = build_graph(data["repos"], data["contributors_map"], + data["languages_map"], data["readmes"], keywords=keywords) + # 热点追踪两翼:飙升项目 + 活跃讨论(图谱之外的"追踪"信号) + result["trending_repos"] = compute_trending(data["repos"]) + result["active_discussions"] = compute_active(data["issues_map"], data["prs_map"]) + + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "graph.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + with open(os.path.join(args.out, "graph.mmd"), "w", encoding="utf-8") as f: + f.write(render_mermaid(result)) + with open(os.path.join(args.out, "graph.dot"), "w", encoding="utf-8") as f: + f.write(render_dot(result)) + print(f"✓ S2 知识图谱完成 → {args.out}/graph.json | report.md | graph.mmd | graph.dot") + print(f" 节点: {result['meta']['node_count']} 边: {result['meta']['edge_count']} " + f"仓库: {result['meta']['repo_count']}") + heat = result["topic_heat"][:5] + print(f" Top 主题: {', '.join(h['topic'] for h in heat)}") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/hotspot.py b/scripts/research/hotspot.py new file mode 100644 index 0000000..2473fa4 --- /dev/null +++ b/scripts/research/hotspot.py @@ -0,0 +1,543 @@ +"""hotspot.py — 科研热点追踪(全栈重构版)。 + +输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库,对每个仓库: + - 拉取 repo_info(stars / forks / 更新时间 / 贡献者) + - 拉取 issue + pr 列表(活跃讨论按评论数排序) + - 从 description + readme 抽取主题标签 + - 聚合学者/团队贡献网络 + +输出:hotspot.json(结构化数据)+ report.md(中文简报)。 + +取数与算法分离:collect() 在线取数,compute_*() 纯函数离线可测。 + +用法: + # 分类精选源(GitLink 官方 pinned,自带 visits)—— 推荐用于缩小范围 + python hotspot.py --category 深度学习 --limit 30 --out ./out + python hotspot.py --category 32 --limit 20 --days 90 # 近 90 天 + # 关键词源(原逻辑) + python hotspot.py --keywords "deep learning,机器学习" --limit 12 --out ./out +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from collections import Counter, defaultdict +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c +import topics as T + + +# --------------------------------------------------------------------------- +# 小工具 +# --------------------------------------------------------------------------- + +def _now_ts() -> float: + return time.time() + + +def _days_ago(ts: float) -> int: + """粗略计算距今多少天(非精确日历差,但足以排序)。""" + return max(0, int((_now_ts() - ts) / 86400)) + + +# --------------------------------------------------------------------------- +# 热度评分(纯函数,离线可测) +# --------------------------------------------------------------------------- + +def compute_trending_score(stars: int, forks: int, updated_days_ago: int, visits: int = 0) -> int: + """仓库热度综合评分。 + + 公式:stars + forks × 2 + visits//10(GitLink 官方访问量)+ 近期更新加成。 + visits 来自 `explore +pinned` 的官方字段;关键词源无该字段时为 0(向后兼容)。 + 满分无上限,用于仓库间横向排序。 + """ + base = stars + forks * 2 + visits // 10 + if updated_days_ago <= 7: + recency = 30 + elif updated_days_ago <= 30: + recency = 20 + elif updated_days_ago <= 90: + recency = 10 + elif updated_days_ago <= 180: + recency = 5 + else: + recency = 0 + return base + recency + + +def compute_velocity(stars: int, updated_days_ago: int) -> float: + """日均星标增速(近似值)。""" + if updated_days_ago <= 0: + updated_days_ago = 1 + return round(stars / max(updated_days_ago, 1), 2) + + +# --------------------------------------------------------------------------- +# 在线取数 +# --------------------------------------------------------------------------- + +def collect(keywords: list[str] | None = None, repos_limit: int = 12, + category: str | None = None, limit: int | None = None, + days: int = 0) -> dict: + """在线取数:候选源(分类精选 OR 关键词搜索)→ 去重 → 截断 → 逐仓详情。 + + - ``category`` 模式:走 ``explore +pinned --category <域>``,取 GitLink 官方精选 + 项目(自带 visits/praises_count/forked_count/topics/language/author)。 + - 关键词模式:多关键词 ``search +repos``(原逻辑)。 + + 其余逐仓 enrichment(repo_info/issues/prs/contributors/readme/languages)两种源共用。 + 返回原始数据字典,供 compute() 消费。 + """ + seen: dict[str, dict] = {} + + def _add(proj: dict, kw: str = ""): + full = c.repo_fullname(proj) + if not full: + return + if full not in seen: + seen[full] = { + "fullname": full, + "matched_keywords": [], + "project": proj, + "info": None, + "issues": [], + "prs": [], + "contributors": [], + "readme": "", + "languages": {}, + } + if kw and kw not in seen[full]["matched_keywords"]: + seen[full]["matched_keywords"].append(kw) + + # Step 1: 候选源 + if category: + n = limit or repos_limit + for proj in c.pinned(category, limit=n): + _add(proj) + else: + for kw in (keywords or []): + for proj in c.search_repos(kw, limit=repos_limit): + _add(proj, kw) + + repos = list(seen.values()) + + # 按 praises + visits//10 粗排,截断到 limit 再深度取数(节省 API) + def _heat_sort_key(r: dict) -> int: + p = r["project"] + return -(c.as_int(p.get("praises_count") or p.get("stars_count") or 0) + + c.as_int(p.get("visits") or 0) // 10) + + cap = limit or repos_limit + repos.sort(key=_heat_sort_key) + repos = repos[:cap] + + # days 过滤(按 last_update_time 近 N 天;0 = 不过滤;过滤后为空则保留原列表) + if days and days > 0: + cutoff = _now_ts() - days * 86400 + fresh = [r for r in repos + if _parse_ts(r["project"].get("last_update_time") or "") >= cutoff] + if fresh: + repos = fresh + + # Step 2: 逐仓库取详情(两源共用) + for r in repos: + full = r["fullname"] + parts = full.split("/", 1) + if len(parts) != 2: + continue + owner, repo = parts[0], parts[1] + info = c.repo_info(owner, repo) + r["info"] = info if isinstance(info, dict) else {} + r["issues"] = c.issues(owner, repo, state="open", max_pages=3, page_size=30) + r["prs"] = c.prs(owner, repo, state="open", max_pages=3, page_size=30) + r["contributors"] = c.contributors(owner, repo) + r["readme"] = (c.readme(owner, repo) or "")[:4000] + r["languages"] = c.languages(owner, repo) + + return { + "keywords": keywords or [], + "category": category or "", + "repos": repos, + "repos_limit": cap, + } + + +# --------------------------------------------------------------------------- +# 离线计算(纯函数,不联网) +# --------------------------------------------------------------------------- + +def compute(raw: dict) -> dict: + """从 collect() 的原始数据计算出所有热点指标。 + + 输入结构见 collect() 返回值;输出为标准 hotspot.json 结构。 + """ + repos_raw = raw.get("repos") or [] + + # ------ trending_repos ------ + trending: list[dict] = [] + now = _now_ts() + for r in repos_raw: + info = r.get("info") or {} + proj = r.get("project") or {} + + stars = c.as_int( + info.get("watchers_count") + or info.get("praises_count") + or proj.get("praises_count") + or 0 + ) + forks = c.as_int( + info.get("forked_count") + or proj.get("forked_count") + or 0 + ) + # 解析更新时间 + update_str = ( + info.get("full_last_update_time") + or info.get("last_update_time") + or proj.get("full_last_update_time") + or proj.get("last_update_time") + or "" + ) + update_ts = _parse_ts(update_str) + days = _days_ago(update_ts) + visits = c.as_int(proj.get("visits") or info.get("visits") or 0) + score = compute_trending_score(stars, forks, days, visits=visits) + velocity = compute_velocity(stars, days) + language = "" + lang_obj = info.get("language") or proj.get("language") + if isinstance(lang_obj, dict): + language = lang_obj.get("name") or "" + elif isinstance(lang_obj, str): + language = lang_obj + + trending.append({ + "repo": r["fullname"], + "description": ( + info.get("description") + or proj.get("description") + or "" + ), + "language": language, + "stars": stars, + "forks": forks, + "visits": visits, + "score": score, + "velocity": velocity, + "matched_keywords": r.get("matched_keywords", []), + "updated": _fmt_ts(update_ts), + "contributors_count": c.as_int(info.get("contributor_users_count") or 0), + "releases_count": c.as_int(info.get("version_releases_count") or 0), + }) + + trending.sort(key=lambda x: -x["score"]) + + # ------ active_discussions ------ + discussions: list[dict] = [] + for r in repos_raw: + full = r["fullname"] + # issues + for iss in r.get("issues") or []: + comments = c.as_int(iss.get("comment_count") or iss.get("comments") or 0) + if comments > 0: + discussions.append({ + "type": "issue", + "repo": full, + "title": iss.get("title") or "(无标题)", + "number": iss.get("index") or iss.get("number") or "", + "state": iss.get("state") or iss.get("status") or "open", + "comments": comments, + }) + # PRs + for pr in r.get("prs") or []: + comments = c.as_int(pr.get("comment_count") or pr.get("comments") or 0) + if comments > 0: + discussions.append({ + "type": "pr", + "repo": full, + "title": pr.get("title") or "(无标题)", + "number": pr.get("index") or pr.get("number") or "", + "state": pr.get("state") or pr.get("status") or "open", + "comments": comments, + }) + + discussions.sort(key=lambda x: -x["comments"]) + + # ------ topic_heat ------ + # 对所有仓库的 description + readme 跑 topic_counter + texts = [] + for r in repos_raw: + info = r.get("info") or {} + proj = r.get("project") or {} + desc = info.get("description") or proj.get("description") or "" + texts.append(desc) + readme = r.get("readme") or "" + if readme: + texts.append(readme) + heat = T.topic_counter(texts) + topic_heat = [ + {"topic": topic, "count": count} + for topic, count in heat.most_common(10) + ] + + # ------ core_scholars / core_teams ------ + scholar_repo_count: Counter = Counter() + # 记录每个 scholar 关联的仓库名 + scholar_repos: dict[str, list[str]] = defaultdict(list) + for r in repos_raw: + full = r["fullname"] + for contrib in r.get("contributors") or []: + login = c.login_of(contrib) + if not login or _is_bot(login): + continue + scholar_repo_count[login] += 1 + if full not in scholar_repos[login]: + scholar_repos[login].append(full) + + core_scholars = [ + { + "login": login, + "repo_count": count, + "repos": scholar_repos.get(login, []), + } + for login, count in scholar_repo_count.most_common(12) + ] + + # 核心团队:按 owner(仓库第一段)聚合 + org_repo: Counter = Counter() + for r in repos_raw: + full = r["fullname"] + org = full.split("/")[0] if "/" in full else full + org_repo[org] += 1 + + core_teams = [ + {"login": org, "repo_count": count} + for org, count in org_repo.most_common(8) + ] + + # ------ meta ------ + total_issues = sum(len(r.get("issues") or []) for r in repos_raw) + total_prs = sum(len(r.get("prs") or []) for r in repos_raw) + + return { + "scenario": "hotspot", + "keywords": raw.get("keywords") or [], + "category": raw.get("category") or "", + "trending_repos": trending, + "active_discussions": discussions, + "topic_heat": topic_heat, + "core_scholars": core_scholars, + "core_teams": core_teams, + "meta": { + "repo_count": len(repos_raw), + "issue_count": total_issues, + "pr_count": total_prs, + "scholar_count": len(scholar_repo_count), + "discussion_count": len(discussions), + "topic_count": len(topic_heat), + }, + } + + +# --------------------------------------------------------------------------- +# 时间工具 +# --------------------------------------------------------------------------- + +def _parse_ts(v: Any) -> float: + """把 GitLink 时间戳/字符串尽量解析为 Unix 浮点秒。""" + if v is None: + return 0.0 + if isinstance(v, (int, float)): + if v > 1_000_000_000_000: + return v / 1000.0 + return float(v) + s = str(v).strip() + if not s: + return 0.0 + # ISO 8601 格式 + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"): + try: + return time.mktime(time.strptime(s[:19] if len(s) >= 19 else s, fmt)) + except ValueError: + continue + return 0.0 + + +def _fmt_ts(ts: float) -> str: + """Unix 浮点秒 → 'YYYY-MM-DD' 字符串。""" + if ts <= 0: + return "—" + try: + return time.strftime("%Y-%m-%d", time.localtime(ts)) + except (ValueError, OSError): + return "—" + + +_BOT_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot") + + +def _is_bot(login: str) -> bool: + low = login.lower() + return any(h in low for h in _BOT_HINTS) + + +# --------------------------------------------------------------------------- +# 报告生成 +# --------------------------------------------------------------------------- + +def _report_md(output: dict) -> str: + """从热点 JSON 产出中文简报 Markdown。""" + kw = ", ".join(output.get("keywords") or []) + category = output.get("category") or "" + meta = output.get("meta") or {} + source_line = f"> 领域分类:**{category}**(GitLink 官方精选)" if category else f"> 关键词:{kw}" + lines = [ + f"# 🔬 科研热点追踪报告", + f"", + source_line, + f"> 扫描时间:{_fmt_ts(_now_ts())}", + f"> 覆盖仓库:{meta.get('repo_count', 0)} 个 " + f"· 讨论 {meta.get('discussion_count', 0)} 条 " + f"· 主题 {meta.get('topic_count', 0)} 个 " + f"· 学者 {meta.get('scholar_count', 0)} 位", + f"", + ] + + # 飙升项目 top 5 + trending = output.get("trending_repos") or [] + if trending: + lines.append("## 🔥 飙升项目 Top 5") + lines.append("") + lines.append("| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 |") + lines.append("|---|------|------|---------|--------|--------|------|------|") + for i, r in enumerate(trending[:5], 1): + lines.append( + f"| {i} | `{r['repo']}` | {r['language'] or '—'} | " + f"{r.get('visits', 0)} | {r['stars']} | {r['forks']} | {r['score']} | {r['updated']} |" + ) + lines.append("") + + # 活跃讨论 top 5 + discussions = output.get("active_discussions") or [] + if discussions: + lines.append("## 💬 活跃讨论 Top 5") + lines.append("") + for i, d in enumerate(discussions[:5], 1): + tp = "🐛 Issue" if d["type"] == "issue" else "🔀 PR" + lines.append( + f"{i}. {tp} [{d['repo']}] {d['title']} " + f"(#{d['number']} · {d['comments']} 💬)" + ) + lines.append("") + + # 热门主题 + topic_heat = output.get("topic_heat") or [] + if topic_heat: + lines.append("## 📊 热门主题") + lines.append("") + for t in topic_heat: + bar = "█" * min(t["count"], 20) + lines.append(f"- **{t['topic']}** — {t['count']} 个仓库 {bar}") + lines.append("") + + # 核心学者 + scholars = output.get("core_scholars") or [] + if scholars: + lines.append("## 👥 核心学者") + lines.append("") + for s in scholars[:5]: + lines.append(f"- **{s['login']}** — 关联 {s['repo_count']} 个仓库") + lines.append("") + + # 核心团队 + teams = output.get("core_teams") or [] + if teams: + lines.append("## 🏛 活跃组织/团队") + lines.append("") + for t in teams: + lines.append(f"- **{t['login']}** — {t['repo_count']} 个仓库") + lines.append("") + + lines.append("---") + lines.append("*由 gitlink-research-hotspot 自动生成*") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI 入口 +# --------------------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser( + description="科研热点追踪 — 分类精选(GitLink 官方) / 关键词 双数据源" + ) + src = ap.add_mutually_exclusive_group() + src.add_argument( + "--category", "-c", default="", + help="GitLink 领域分类(中文名或 id,如 深度学习 / 32)—— 官方精选源", + ) + src.add_argument( + "--keywords", "-k", default="", + help="搜索关键词,逗号分隔(关键词源)", + ) + ap.add_argument("--limit", type=int, default=20, help="最大分析仓库数(默认 20)") + ap.add_argument("--repos-limit", type=int, default=12, help="(兼容旧参数,等价 --limit)") + ap.add_argument("--days", type=int, default=0, + help="只取近 N 天更新的仓库(0=不过滤;仅 --category 源生效)") + ap.add_argument("--out", "-o", default="", help="输出目录(不传则仅打印 JSON 到 stdout)") + args = ap.parse_args() + + category = args.category.strip() + kw_list = [k.strip() for k in args.keywords.split(",") if k.strip()] if args.keywords else [] + if not category and not kw_list: + print(json.dumps({"ok": False, "error": "need --category OR --keywords"}, + ensure_ascii=False)) + sys.exit(1) + + cap = args.limit or args.repos_limit + + # 取数 + if category: + sys.stderr.write(f"[hotspot] 分类精选: {category} 上限: {cap} 近 {args.days or '∞'} 天\n") + else: + sys.stderr.write(f"[hotspot] 关键词: {kw_list} 上限: {cap}\n") + sys.stderr.flush() + raw = collect(keywords=kw_list or None, repos_limit=cap, + category=category or None, limit=cap, days=args.days) + + # 计算 + sys.stderr.write(f"[hotspot] 仓库: {len(raw['repos'])} 计算热点…\n") + sys.stderr.flush() + output = compute(raw) + + json_text = json.dumps(output, ensure_ascii=False, indent=2) + + if args.out: + os.makedirs(args.out, exist_ok=True) + json_path = os.path.join(args.out, "hotspot.json") + with open(json_path, "w", encoding="utf-8") as f: + f.write(json_text) + report_path = os.path.join(args.out, "report.md") + with open(report_path, "w", encoding="utf-8") as f: + f.write(_report_md(output)) + sys.stderr.write( + f"[hotspot] ✓ 完成 " + f"仓库={output['meta']['repo_count']} " + f"讨论={output['meta']['discussion_count']} " + f"主题={output['meta']['topic_count']} " + f"学者={output['meta']['scholar_count']}\n" + ) + sys.stderr.write(f"[hotspot] 产物: {json_path}, {report_path}\n") + sys.stderr.flush() + else: + print(json_text) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/inspire.py b/scripts/research/inspire.py new file mode 100644 index 0000000..49dc4d7 --- /dev/null +++ b/scripts/research/inspire.py @@ -0,0 +1,234 @@ +""" +创新启发(Pillar 4):缺口挖掘 + 合作者匹配 + 创新点 + LLM 研究方向建议 + +模式: + --owner O --repo R 单仓:缺口/匹配/创新点 + LLM idea + --category <域> 领域级:聚合分类下 top 仓的缺口 + 领域主题 + LLM idea + +LLM 可选:配置 DEEPSEEK_API_KEY 才生成 idea;无 key 确定性产出照常。 +产物:inspire.json + report.md。 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import Counter + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c +import topics as T +import match as M +import lineage as L +import hotspot as H +import llm + +SYS_PROMPT = ( + "你是资深科研协作顾问。基于给定的 GitLink 仓库/领域缺口信号、可合作学者、近期创新点," + "提出 3-5 条具体、可执行的研究方向或合作建议。要求:紧扣缺口主题、指出可切入的角度、" + "点名潜在合作者类型。用中文,分条输出(1. 2. 3.),每条不超过 80 字。" +) + + +# --------------------------------------------------------------------------- +# 单仓创新启发 +# --------------------------------------------------------------------------- + +def analyze_repo(owner: str, repo: str, top: int = 8, pool_cap: int = 12, + context: dict | None = None) -> dict: + """单仓:缺口 + 匹配 + 创新点 + LLM idea。""" + m = M.match(owner, repo, top=top, pool_cap=pool_cap) # gap_topics/signals/candidates + innovations: list[dict] = [] + try: + innovations = (L.lineage(owner, repo) or {}).get("innovation_points") or [] + except Exception as e: + sys.stderr.write(f"[inspire] lineage 取创新点失败: {e!r}\n") + + idea = _llm_idea_repo(owner, repo, m, innovations, context) + + return { + "scenario": "inspire", + "mode": "repo", + "repo": f"{owner}/{repo}", + "gap_topics": m.get("gap_topics") or [], + "needed_languages": m.get("needed_languages") or [], + "gap_signals": (m.get("gap_signals") or [])[:12], + "candidates": m.get("candidates") or [], + "innovation_points": innovations[:6], + "idea": idea, + "llm_used": idea is not None, + } + + +def _llm_idea_repo(owner: str, repo: str, m: dict, innovations: list[dict], + context: dict | None) -> str | None: + if not llm.available(): + return None + gaps = ", ".join(f"{t}" for t in (m.get("gap_topics") or [])[:6]) or "(无明显缺口主题)" + cands = "; ".join( + f"{x.get('login')}(契合{x.get('score')}, 重叠{x.get('topic_overlap')})" + for x in (m.get("candidates") or [])[:3]) or "(暂无候选)" + inno = "; ".join((x.get("description") or "")[:50] for x in innovations[:3]) or "(暂无)" + cat_line = "" + if context and context.get("category"): + cat_line = f"\n所属领域热点分类:{context['category']};领域热门主题:{', '.join(context.get('topic_heat', [])[:5])}" + prompt = ( + f"仓库:{owner}/{repo}{cat_line}\n" + f"缺口主题(按权重):{gaps}\n" + f"可合作学者:{cands}\n" + f"近期创新点:{inno}\n\n" + f"请基于以上给出 3-5 条研究方向/合作建议。" + ) + return llm.chat(prompt, system=SYS_PROMPT) + + +# --------------------------------------------------------------------------- +# 领域级创新启发 +# --------------------------------------------------------------------------- + +def analyze_category(category: str, repos_limit: int = 12, top_repos_for_gap: int = 3) -> dict: + """领域级:聚合分类下 top 仓的缺口 + 领域主题热度 + LLM idea。""" + raw = H.collect(category=category, limit=repos_limit) + hot = H.compute(raw) + topic_heat = [t["topic"] for t in (hot.get("topic_heat") or [])[:8]] + scholars = [s["login"] for s in (hot.get("core_scholars") or [])[:5]] + + # 聚合 top-N 仓的缺口(轻量:每仓 build_gap_signals,累加 gap_topics) + agg: Counter = Counter() + gap_signals_agg: list[dict] = [] + repos = hot.get("trending_repos") or [] + for r in repos[:top_repos_for_gap]: + full = r.get("repo") or "" + if "/" not in full: + continue + o, rr = full.split("/", 1) + try: + info = c.repo_info(o, rr) or {} + gt, _langs, sigs = M.build_gap_signals(o, rr, info, issue_sample=40) + agg.update({k: v for k, v in gt.items()}) + for s in sigs[:3]: + s2 = dict(s); s2["repo"] = full; gap_signals_agg.append(s2) + except Exception as e: + sys.stderr.write(f"[inspire] {full} 缺口采集失败: {e!r}\n") + + idea = _llm_idea_category(category, topic_heat, agg, scholars) + + return { + "scenario": "inspire", + "mode": "category", + "category": category, + "topic_heat": topic_heat, + "gap_topics": [t for t, _ in agg.most_common(10)], + "gap_signals": gap_signals_agg[:12], + "core_scholars": scholars, + "idea": idea, + "llm_used": idea is not None, + } + + +def _llm_idea_category(category: str, topic_heat: list[str], gaps: Counter, + scholars: list[str]) -> str | None: + if not llm.available(): + return None + gap_str = ", ".join(f"{t}({n})" for t, n in gaps.most_common(8)) or "(无明显缺口)" + prompt = ( + f"GitLink 领域:{category}\n" + f"热门主题:{', '.join(topic_heat[:6])}\n" + f"缺口主题(主题词频):{gap_str}\n" + f"核心活跃学者:{', '.join(scholars)}\n\n" + f"请基于该领域的热点与缺口,给出 3-5 条具切入价值的研究方向建议。" + ) + return llm.chat(prompt, system=SYS_PROMPT) + + +# --------------------------------------------------------------------------- +# 报告 +# --------------------------------------------------------------------------- + +def render_report(result: dict) -> str: + lines = ["# 💡 创新启发报告", ""] + if result.get("mode") == "category": + lines.append(f"> 领域:**{result.get('category')}**(领域级缺口 + LLM 建议)") + else: + lines.append(f"> 焦点仓库:`{result.get('repo')}`") + lines.append(f"> LLM 建议:{'✅ 已生成' if result.get('llm_used') else '⏭ 未启用(无 DEEPSEEK_API_KEY)'}") + lines.append("") + + gaps = result.get("gap_topics") or [] + if gaps: + lines.append("## 🎯 缺口主题") + lines.append(", ".join(f"`{g}`" for g in gaps[:10])) + lines.append("") + + sigs = result.get("gap_signals") or [] + if sigs: + lines.append("## 🚩 缺口信号(未解决 Issue)") + for s in sigs[:6]: + lines.append(f"- [{s.get('priority') or '—'}] {s.get('topic')}:{(s.get('evidence') or '')[:70]} · `{s.get('repo','')}`") + lines.append("") + + cands = result.get("candidates") or [] + if cands: + lines.append("## 🤝 可合作学者 Top 5") + lines.append("| 学者 | 契合度 | 主题重叠 | 语言匹配 | 活跃 | 理由 |") + lines.append("|---|---|---|---|---|---|") + for x in cands[:5]: + lines.append(f"| `{x.get('login')}` | {x.get('score')} | {x.get('topic_overlap')} | " + f"{x.get('language_match')} | {x.get('activity_level')} | {';'.join(x.get('reasons', [])[:2])} |") + lines.append("") + + inno = result.get("innovation_points") or [] + if inno: + lines.append("## 🌟 近期创新点") + for i in inno[:5]: + lines.append(f"- {i.get('description')} _({i.get('category','')})_") + lines.append("") + + idea = result.get("idea") + if idea: + lines.append("## 🧠 LLM 研究方向建议") + lines.append("") + lines.append(idea.strip()) + lines.append("") + + lines.append("---\n*由 gitlink-research-inspire 生成*") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description="创新启发 — 缺口/匹配/创新点 + LLM idea") + ap.add_argument("--owner", default="") + ap.add_argument("--repo", default="") + ap.add_argument("--category", "-c", default="", help="领域分类(领域级模式)") + ap.add_argument("--top", type=int, default=8) + ap.add_argument("--pool", type=int, default=12) + ap.add_argument("--out", "-o", default="") + args = ap.parse_args() + + if args.category: + result = analyze_category(args.category) + elif args.owner and args.repo: + result = analyze_repo(args.owner, args.repo, top=args.top, pool_cap=args.pool) + else: + print(json.dumps({"ok": False, "error": "need --owner/--repo OR --category"}, ensure_ascii=False)) + sys.exit(1) + + text = json.dumps(result, ensure_ascii=False, indent=2) + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "inspire.json"), "w", encoding="utf-8") as f: + f.write(text) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + sys.stderr.write(f"[inspire] ✓ mode={result['mode']} llm={result['llm_used']} 产物落 {args.out}\n") + else: + print(text) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/lineage.py b/scripts/research/lineage.py new file mode 100644 index 0000000..6e8b86c --- /dev/null +++ b/scripts/research/lineage.py @@ -0,0 +1,497 @@ +""" +S1 仓库级科研项目谱系分析 + +分析维度: 提交时间线 / PR 演进模式 / 文档演化 / 创新点识别 / 分支地图 +所有 GitLink 操作经 gitlink-cli,禁止用 gh/glab。统一使用 main 分支。 + +用法: + python lineage.py --owner mindspore-Ecosystem --repo mindspore --out ./out +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from collections import Counter +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 + +# --------------------------------------------------------------------------- +# 提交时间解析(commit timestamp 可能是 ISO 字符串或整数秒) +# --------------------------------------------------------------------------- + +def _to_epoch(v: Any) -> float: + """把 GitLink commit 的 timestamp(ISO 字符串或整数秒)统一解析为 epoch 秒。 + + 失败返回 0.0。 + """ + if v is None: + return 0.0 + if isinstance(v, (int, float)): + # 毫秒级时间戳兜底(GitLink 多为秒) + return float(v) / 1000.0 if v > 1e12 else float(v) + s = str(v).strip() + if not s: + return 0.0 + # 纯数字串 + if s.isdigit(): + f = float(s) + return f / 1000.0 if f > 1e12 else f + # ISO 8601:'2024-05-01T08:00:00Z' / '2024-05-01 08:00:00' + s = s.replace("Z", "+00:00") + try: + import datetime as _dt + return _dt.datetime.fromisoformat(s).timestamp() + except (ValueError, TypeError): + # 退而求其次:抽首个 YYYY-MM-DD + m = re.search(r"(\d{4})-(\d{2})-(\d{2})", str(v)) + if m: + try: + import datetime as _dt + return _dt.datetime(int(m.group(1)), int(m.group(2)), + int(m.group(3))).timestamp() + except ValueError: + return 0.0 + return 0.0 + + +def _iso_date(epoch: float) -> str: + """epoch 秒 → 'YYYY-MM-DD' 字符串(0 → '')。""" + if not epoch: + return "" + import datetime as _dt + try: + return _dt.datetime.utcfromtimestamp(epoch).strftime("%Y-%m-%d") + except (OSError, ValueError, OverflowError): + return "" + + +# --------------------------------------------------------------------------- +# 分类器:实验/评测文件 vs 文档文件 +# --------------------------------------------------------------------------- + +# 命中即判为「实验/评测/数据」类文件(科研产物信号)。 +# 匹配「目录段或文件名」:以 experiment/benchmark/eval/test(s)/data 开头 +# (后接 [\w-]* 续写,或紧接分隔符/串尾)。 +_EXPERIMENT_RE = re.compile( + r"(^|[_/\-])(experiment[\w-]*|benchmark[\w-]*|eval[\w-]*|tests?|data)([/\-\._]|$)", + re.IGNORECASE, +) + + +def is_experiment_file(path: str) -> bool: + """判断文件路径是否属于「实验/评测/数据」类(科研产物)。 + + 命中 experiment*/benchmark*/eval*/tests?/data/ 任意一段(作为目录名或文件名前缀) + 返回 True。例如: + - experiments/run.py, experiment_train.py, benchmark/eval.py + - tests/test_x.py, data/dataset.csv, src/benchmark_infer.py + """ + if not path: + return False + return bool(_EXPERIMENT_RE.search(path)) + + +def is_doc_file(path: str) -> bool: + """判断文件路径是否属于文档类:*.md(任意位置)或 docs/ 下任意文件。""" + if not path: + return False + low = path.lower() + if low.endswith(".md"): + return True + return low.startswith("docs/") or ("/docs/" in low) + + +# --------------------------------------------------------------------------- +# 分支地图(单分支简化:默认分支为唯一分支) +# --------------------------------------------------------------------------- + +def build_branch_map(commits: list[dict], default_branch: str) -> list[dict]: + """构建分支活跃度地图。本场景单分支简化:默认分支为唯一分支。 + + 返回 [{name, commits, last_active, is_default}]。 + """ + n = len(commits) if isinstance(commits, list) else 0 + last = 0.0 + for cm in commits or []: + t = _to_epoch(cm.get("timestamp")) + if t and t > last: + last = t + return [{ + "name": default_branch or "master", + "commits": n, + "last_active": _iso_date(last), + "is_default": True, + }] + + +# --------------------------------------------------------------------------- +# 提交时间线(按周/按日聚合) +# --------------------------------------------------------------------------- + +def commit_timeline(commits: list[dict], bucket: str = "day") -> list[dict]: + """把提交按日期聚合为时间线,返回按日期升序的 [{date, count}]。 + + bucket ∈ {'day','week'};week 以 ISO 年-周 表示。 + """ + counter: Counter = Counter() + for cm in commits or []: + t = _to_epoch(cm.get("timestamp")) + if not t: + continue + if bucket == "week": + import datetime as _dt + iso = _dt.datetime.utcfromtimestamp(t).isocalendar() + key = f"{iso[0]}-W{iso[1]:02d}" + else: + key = _iso_date(t) + counter[key] += 1 + return [{"date": k, "count": counter[k]} for k in sorted(counter)] + + +# --------------------------------------------------------------------------- +# 合并 PR 的演进模式 +# --------------------------------------------------------------------------- + +def pr_merge_patterns(merged_prs: list[dict]) -> list[dict]: + """从已合并 PR 抽取演进模式。 + + 返回 [{number, title, status, merged_time, changed_files}]。 + changed_files 取 pr 提供的 changed_files / changedFiles / additions/deletions 的近似。 + """ + out: list[dict] = [] + for pr in merged_prs or []: + if not isinstance(pr, dict): + continue + # 时间:优先 merged 时间字段,否则 pr_created_unix + merged_t = (pr.get("pr_merged_unix") or pr.get("merged_at") + or pr.get("pr_updated_unix") or pr.get("pr_created_unix")) + # 文件改动数:优先详情接口的 files_count(列表 API 不返回) + changed = (pr.get("files_count") or pr.get("changed_files") + or pr.get("changedFiles") or pr.get("file_nums") or 0) + # 兜底:用 additions/deletions 之和近似 + if not changed: + changed = c.as_int(pr.get("additions"), 0) + c.as_int(pr.get("deletions"), 0) + # 标题只取首行 + 截断到 80 字(有些 PR 创建时把正文塞进了 title 字段) + raw_title = c.as_str(pr.get("title")).split("\n", 1)[0].strip() + title = raw_title[:80] + out.append({ + "number": pr.get("index") or pr.get("number") or pr.get("id"), + "title": title, + "status": pr.get("status"), + "merged_time": _iso_date(_to_epoch(merged_t)), + "changed_files": c.as_int(changed), + }) + # 按合并时间升序(空时间排末尾) + out.sort(key=lambda x: (x["merged_time"] == "", x["merged_time"])) + return out + + +# --------------------------------------------------------------------------- +# 文档演进(docs/*.md 的近似最后修改信息) +# --------------------------------------------------------------------------- + +def doc_evolution(tree_entries: list[dict]) -> list[dict]: + """从仓库树抽取 docs/ 下的文档清单,近似其最后修改日期。 + + tree 来自 c.tree(owner, repo, path='docs')。每条 entry 形如 + {name, path, type, ...}。最后修改日期在树端点通常不可得,用文件名中的 + 日期或留空(report 中标注「近似」)。 + 返回 [{file, last_date}]。 + """ + out: list[dict] = [] + for e in tree_entries or []: + if not isinstance(e, dict): + continue + name = c.as_str(e.get("name") or e.get("path")) + path = c.as_str(e.get("path") or name) + if not is_doc_file(path): + continue + last = c.as_str(e.get("last_commit") or e.get("commit_date") + or e.get("date")) + if not last: + m = re.search(r"(\d{4})-(\d{2})-(\d{2})", path) + last = m.group(0) if m else "" + out.append({"file": name, "last_date": last}) + out.sort(key=lambda x: (x["last_date"] == "", x["file"])) + return out + + +# --------------------------------------------------------------------------- +# 创新点识别(高影响合并) +# --------------------------------------------------------------------------- + +def innovation_points(merged_prs: list[dict], commits: list[dict], + top: int = 8) -> list[dict]: + """识别高影响合并作为项目的创新/里程碑点。 + + 判据(任一): + - 改动文件数高(>= 中位数的 1.5 倍,或绝对值 >= 10)→ 「大规模重构/新特性」 + - 标题含里程碑关键词(add/implement/feature/release/benchmark/...)→ 「特性引入」 + 返回 [{description, evidence, category}],按影响度降序取前 top。 + """ + patterns = pr_merge_patterns(merged_prs) + if not patterns: + return [] + + files = [p["changed_files"] for p in patterns if p["changed_files"] > 0] + median = sorted(files)[len(files) // 2] if files else 0 + + MILESTONE_RE = re.compile( + r"(add|implement|support|feature|release|benchmark|refactor|" + r"experiment|dataset|train|inference|v\d+\.\d+)", re.IGNORECASE) + + scored: list[tuple[float, dict]] = [] + for p in patterns: + cf = p["changed_files"] + title = p["title"] + category = "" + impact = float(cf) + if cf >= 10 or (median and cf >= median * 1.5): + category = "大规模重构/新特性" + impact += 10 + if MILESTONE_RE.search(title): + category = category or "特性引入" + impact += 5 + if not category: + continue + evidence = (f"PR #{p['number']} 「{title[:48]}」 " + f"改动 {cf} 文件,合并于 {p['merged_time'] or '未知时间'}") + scored.append((impact, { + "description": title.strip() or f"PR #{p['number']}", + "evidence": evidence, + "category": category, + })) + scored.sort(key=lambda x: -x[0]) + return [item for _, item in scored[:top]] + + +# --------------------------------------------------------------------------- +# 主流程:取数 + 算法 +# --------------------------------------------------------------------------- + +def lineage(owner: str, repo: str, branches_limit: int = 5) -> dict[str, Any]: + """取数 + 分析,返回完整 lineage 结果 dict。""" + info = c.repo_info(owner, repo) + default_branch = "main" # 统一使用 main 分支分析 + + commits = c.commits(owner, repo, ref=default_branch, max_pages=10, page_size=100) + # 已合并 PR:state=merged(collect 透传) + merged_prs = c.prs(owner, repo, state="merged", max_pages=10, page_size=50) + # 列表 API 不返回文件改动数 → 逐个取详情补 files_count(限速 + 上限 30 个,控 API 调用) + for pr in merged_prs[:30]: + idx = pr.get("index") or pr.get("number") or pr.get("id") + if idx is None: + continue + try: + det = c.pr_detail(owner, repo, int(idx)) + except (TypeError, ValueError): + det = {} + if det: + if det.get("files_count") is not None: + pr["files_count"] = det.get("files_count") + if det.get("commits_count") is not None: + pr["commits_count"] = det.get("commits_count") + tree_root = c.tree(owner, repo, ref=default_branch) + docs_tree = c.tree(owner, repo, path="docs", ref=default_branch) + _readme = c.readme(owner, repo, ref=default_branch) + + # 扫描整棵树,挑出实验/评测文件 + exp_files: list[str] = [] + all_tree = (tree_root or []) + (docs_tree or []) + for e in all_tree: + if not isinstance(e, dict): + continue + p = c.as_str(e.get("path") or e.get("name")) + if p and is_experiment_file(p): + exp_files.append(p) + + timeline = commit_timeline(commits, bucket="day") + branch_map = build_branch_map(commits, default_branch)[:branches_limit] + pr_patterns = pr_merge_patterns(merged_prs) + docs = doc_evolution(docs_tree if docs_tree else tree_root) + innovations = innovation_points(merged_prs, commits) + + return { + "scenario": "S1_repository_research_insight", + "repo": f"{owner}/{repo}", + "default_branch": default_branch, + "commit_timeline": timeline, + "branch_map": branch_map, + "pr_merge_patterns": pr_patterns, + "doc_evolution": docs, + "experiment_files": sorted(set(exp_files)), + "innovation_points": innovations, + "meta": { + "commit_count": len(commits), + "merged_pr_count": len(merged_prs), + "doc_count": len(docs), + "experiment_file_count": len(exp_files), + }, + } + + +# --------------------------------------------------------------------------- +# 渲染:Markdown 报告 + Mermaid gitGraph +# --------------------------------------------------------------------------- + +def render_report(result: dict[str, Any]) -> str: + repo = result["repo"] + meta = result["meta"] + lines = [ + f"# 仓库级科研项目洞悉报告 — {repo}\n", + f"> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析\n", + "## 一、基础信息\n", + f"- **默认分支**: `{result['default_branch']}`", + f"- **采样提交**: {meta['commit_count']} 条(默认分支,最多 10×100)", + f"- **已合并 PR**: {meta['merged_pr_count']} 个", + f"- **文档文件**: {meta['doc_count']} 个", + f"- **实验/评测文件**: {meta['experiment_file_count']} 个\n", + ] + + lines.append("## 二、提交活跃度时间线\n") + tl = result["commit_timeline"] + if tl: + peak = max(tl, key=lambda x: x["count"]) + lines.append(f"- 时间跨度: {tl[0]['date']} → {tl[-1]['date']}" + f"(共 {len(tl)} 个有提交的日期)") + lines.append(f"- 峰值: {peak['date']} 当日 {peak['count']} 次提交\n") + else: + lines.append("- (未取到提交时间线)\n") + + lines.append("## 三、分支地图\n") + lines.append("| 分支 | 提交数 | 最后活跃 | 是否默认 |") + lines.append("|------|:------:|----------|:--------:|") + for b in result["branch_map"]: + lines.append(f"| `{b['name']}` | {b['commits']} | {b['last_active'] or '—'} |" + f" {'是' if b['is_default'] else '否'} |") + lines.append("") + + lines.append("## 四、合并 PR 演进模式(高影响合并预览)\n") + prs = result["pr_merge_patterns"] + if prs: + lines.append("| PR | 标题 | 改动文件 | 合并时间 |") + lines.append("|----|------|:--------:|----------|") + for p in prs[:10]: + lines.append(f"| #{p['number']} | {p['title']} | " + f"{p['changed_files']} | {p['merged_time'] or '—'} |") + else: + lines.append("- (无已合并 PR)") + lines.append("") + + lines.append("## 五、创新/里程碑点\n") + inno = result["innovation_points"] + if inno: + for i, it in enumerate(inno, 1): + lines.append(f"{i}. **[{it['category']}]** {it['description']}") + lines.append(f" - 证据: {it['evidence']}") + else: + lines.append("- (未识别到明显高影响合并)") + lines.append("") + + lines.append("## 六、文档演进(docs/*)\n") + docs = result["doc_evolution"] + if docs: + lines.append("| 文档 | 近似最后日期 |") + lines.append("|------|--------------|") + for d in docs[:15]: + lines.append(f"| {d['file']} | {d['last_date'] or '—'} |") + else: + lines.append("- (docs/ 下无文档或树不可得)") + lines.append("") + + lines.append("## 七、实验/评测文件组织\n") + exps = result["experiment_files"] + if exps: + for p in exps[:20]: + lines.append(f"- `{p}`") + if len(exps) > 20: + lines.append(f"- ...(共 {len(exps)} 个,此处仅列前 20)") + else: + lines.append("- (未在仓库树中识别到 experiment/benchmark/eval/test/data 目录)") + lines.append("") + return "\n".join(lines) + + +def render_mermaid(result: dict[str, Any]) -> str: + """渲染 Mermaid gitGraph:用 commit 链展示项目演进。有 PR 时用 branch→merge, + 无 PR 时回退到按 commits 简化展示。""" + lines = ["```mermaid", "gitGraph"] + branch_name = result.get("default_branch", "main") + lines.append(f" commit id: \"{branch_name} 起点\"") + prs = result.get("pr_merge_patterns") or [] + inno = result.get("innovation_points") or [] + inno_nums = set() + for it in inno: + ev = it.get("evidence", "") + m = re.search(r"PR #(\d+)", ev) + if m: + inno_nums.add(int(m.group(1))) + + if prs: + # 有 PR:每个 PR 作为 feature 分支 → merge 回主线 + for i, p in enumerate(prs[:15]): + pnum = p.get("number") or i + 1 + title = (p.get("title") or "")[:24] + is_innov = pnum in inno_nums + tag = " ✨创新" if is_innov else "" + safe_branch = f"pr{pnum}" + lines.append(f" branch {safe_branch}") + lines.append(f" checkout {safe_branch}") + lines.append(f" commit id: \"#{pnum}{tag}: {title}\"") + lines.append(f" checkout {branch_name}") + lines.append(f" merge {safe_branch}") + else: + # 无 PR 数据:用 commit 时间线简化展示 + timeline = result.get("commit_timeline") or [] + shown = 0 + for entry in timeline: + if shown >= 12: + break + if entry.get("count", 0) > 0: + lines.append(f" commit id: \"{entry['date']} ({entry['count']} commits)\"") + shown += 1 + if shown == 0: + lines.append(" commit id: \"(暂无提交数据)\"") + + lines.append(f" commit id: \"HEAD\"") + lines.append("```") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S1 仓库级科研项目洞悉(lineage 谱系分析)") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--branches-limit", type=int, default=5, + help="分支地图上限(本场景单分支简化,默认 5)") + ap.add_argument("--out", help="输出目录(写 lineage.json/report.md/branch_graph.mmd);" + "省略则打印 JSON") + args = ap.parse_args() + + result = lineage(args.owner, args.repo, branches_limit=args.branches_limit) + + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "lineage.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + with open(os.path.join(args.out, "branch_graph.mmd"), "w", encoding="utf-8") as f: + f.write(render_mermaid(result)) + print(f"✓ S1 项目洞悉完成 → {args.out}/lineage.json | report.md | branch_graph.mmd") + print(f" 提交 {result['meta']['commit_count']} 条 | " + f"合并 PR {result['meta']['merged_pr_count']} 个 | " + f"创新点 {len(result['innovation_points'])} 个") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/llm.py b/scripts/research/llm.py new file mode 100644 index 0000000..092ab40 --- /dev/null +++ b/scripts/research/llm.py @@ -0,0 +1,93 @@ +"""llm.py — 可选 LLM 客户端(DeepSeek,OpenAI 兼容),用于创新启发生成 idea。 + +无 DEEPSEEK_API_KEY 时 chat() 返回 None(调用方降级,不中断)。纯 stdlib,无新依赖。 + +环境变量: + DEEPSEEK_API_KEY (或 LLM_API_KEY) — 必填才启用 + DEEPSEEK_BASE_URL (或 LLM_BASE_URL) — 默认 https://api.deepseek.com + DEEPSEEK_MODEL (或 LLM_MODEL) — 默认 deepseek-chat + +冒烟:python llm.py "说一句你好" +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + +DEFAULT_BASE = "https://api.deepseek.com" +DEFAULT_MODEL = "deepseek-chat" +TIMEOUT = 60 + + +def _api_key() -> str: + return os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("LLM_API_KEY") or "" + + +def _base_url() -> str: + return (os.environ.get("DEEPSEEK_BASE_URL") or os.environ.get("LLM_BASE_URL") or DEFAULT_BASE).rstrip("/") + + +def _model() -> str: + return os.environ.get("DEEPSEEK_MODEL") or os.environ.get("LLM_MODEL") or DEFAULT_MODEL + + +def available() -> bool: + """是否配置了 key(链路据此决定是否调用 LLM)。""" + return bool(_api_key()) + + +def chat(prompt: str, system: str | None = None, model: str | None = None, + max_tokens: int = 900, temperature: float = 0.7) -> str | None: + """调用 DeepSeek(OpenAI 兼容 /chat/completions)。 + + 返回助手回复文本;无 key、网络/解析出错则返回 None(调用方应降级)。 + """ + key = _api_key() + if not key: + return None + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + body = { + "model": model or _model(), + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": False, + } + req = urllib.request.Request( + _base_url() + "/chat/completions", + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + data = json.loads(r.read().decode("utf-8")) + choices = data.get("choices") or [] + if choices: + return (choices[0].get("message") or {}).get("content") + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as e: + sys.stderr.write(f"[llm] 调用失败(链路将降级): {e!r}\n") + return None + + +def main() -> None: + import argparse + ap = argparse.ArgumentParser(description="DeepSeek LLM 冒烟测试") + ap.add_argument("prompt", nargs="?", default="说一句你好", help="prompt") + ap.add_argument("--system", default=None) + args = ap.parse_args() + if not available(): + print("(no DEEPSEEK_API_KEY / LLM_API_KEY — chat 不可用;链路会自动降级,确定性产出照常)") + return + out = chat(args.prompt, system=args.system) + print(out or "(LLM 返回空)") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/match.py b/scripts/research/match.py new file mode 100644 index 0000000..d578cda --- /dev/null +++ b/scripts/research/match.py @@ -0,0 +1,320 @@ +"""match.py — S4 科研协作智能匹配。 + +输入一个科研仓库,分析其技术缺口(未解决 Issue 的主题/语言、开放 PR、研究空缺), +再从 GitLink 平台候选池(本仓库贡献者 + 按缺口主题搜索到的用户)中,用「主题向量 + 语言匹配 + +活跃度 + 协作开放度」综合打分,推荐最合适的跨团队/跨学者协作伙伴。 + +数据全部经 gitlink-cli 获取(issue +list / repo +contributors / repo +list --user / search +users)。 + +用法: + python match.py --owner mindspore-Ecosystem --repo mindspore --top 10 --out ./out + python match.py --owner O --repo R --format json # 仅打印 JSON +""" +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from collections import Counter +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 +import topics as T # noqa: E402 + +# 优先级 → 权重(Issue 缺口信号加权) +PRIORITY_WEIGHT = {"urgent": 3, "high": 3, "紧急": 3, "高": 3, + "normal": 2, "medium": 2, "普通": 2, "中": 2, + "low": 1, "低": 1} + + +# --------------------------------------------------------------------------- +# 向量与打分 +# --------------------------------------------------------------------------- + +def cosine(c1: dict[str, float], c2: dict[str, float]) -> float: + keys = set(c1) | set(c2) + dot = sum(c1.get(k, 0.0) * c2.get(k, 0.0) for k in keys) + n1 = math.sqrt(sum(v * v for v in c1.values())) + n2 = math.sqrt(sum(v * v for v in c2.values())) + return dot / (n1 * n2) if n1 and n2 else 0.0 + + +def jaccard(a: list[str], b: list[str]) -> float: + sa, sb = set(a), set(b) + if not sa or not sb: + return 0.0 + return len(sa & sb) / len(sa | sb) + + +def _priority_weight(issue: dict) -> float: + p = (issue.get("priority_name") or issue.get("priority") or "").lower() + for k, w in PRIORITY_WEIGHT.items(): + if k in str(p): + return float(w) + return 1.0 + + +def _parse_ratio(v: Any) -> float: + """把 '1.18%' / '0.2' / 0.2 等统一解析为 0~1 比例。""" + if v is None: + return 0.0 + s = str(v).strip() + pct = s.endswith("%") + if pct: + s = s[:-1] + try: + f = float(s) + except ValueError: + return 0.0 + return f / 100.0 if (pct or f > 1.0) else f + + +# --------------------------------------------------------------------------- +# 缺口信号 +# --------------------------------------------------------------------------- + +def build_gap_signals(owner: str, repo: str, info: dict, + issue_sample: int = 100) -> tuple[Counter, list[str], list[dict]]: + """返回 (缺口主题词频 Counter, 需求语言列表, 缺口信号明细)。""" + gap_topics: Counter = Counter() + gap_langs: set[str] = set() + signals: list[dict] = [] + + # 1) 仓库自身主题/语言(协作者应具备的基础方向) + desc = (info.get("description") or "") + " " + c.readme(owner, repo)[:4000] + for tp in T.extract_topics(desc): + gap_topics[tp] += 1 + for lg in c.languages(owner, repo): + gap_langs.add(lg) + for lg in T.extract_languages(desc): + gap_langs.add(lg) + + # 2) 未解决 Issue 的主题/语言(核心缺口) + open_issues = c.issues(owner, repo, state="open", max_pages=max(1, issue_sample // 50), + page_size=50) + for iss in open_issues[:issue_sample]: + text = (iss.get("subject") or iss.get("title") or "") + w = _priority_weight(iss) + for tp in T.extract_topics(text): + gap_topics[tp] += w + for lg in T.extract_languages(text): + gap_langs.add(lg) + # 取每条 issue 的首个主题作为该条的证据 + tps = T.extract_topics(text) + if tps: + signals.append({"type": "unresolved_issue", "topic": tps[0], + "evidence": text[:80], "priority": iss.get("priority_name", "")}) + + # 3) 开放 PR 正在推进的方向(轻量加成) + for pr in c.prs(owner, repo, state="open", max_pages=1, page_size=30): + text = (pr.get("title") or "") + " " + (pr.get("body") or "") + for tp in T.extract_topics(text): + gap_topics[tp] += 0.5 + + needed_langs = sorted(gap_langs) + return gap_topics, needed_langs, signals + + +# --------------------------------------------------------------------------- +# 候选人画像与匹配 +# --------------------------------------------------------------------------- + +def candidate_pool(owner: str, repo: str, gap_topics: Counter, pool_cap: int) -> list[str]: + """候选人 login 池:本仓库贡献者 + 按缺口主题搜到的外部用户。""" + seen: list[str] = [] + seen_set: set[str] = set() + + for contrib in c.contributors(owner, repo): + login = c.login_of(contrib) + # 过滤明显机器人账号 + if login and login not in seen_set and "bot" not in login.lower() and login.lower() != "i-robot": + seen.append(login) + seen_set.add(login) + if len(seen) >= pool_cap: + return seen + + # 取词频最高的若干主题,用其英文关键词搜外部用户 + top_topics = [t for t, _ in gap_topics.most_common(5)] + eng_kw = {"nlp": "nlp", "deep_learning": "deep learning", "computer_vision": "cv", + "reinforcement_learning": "reinforcement learning", + "graph_learning": "gnn", "federated_learning": "federated", + "scientific_computing": "cuda", "data_mining": "machine learning", + "devops": "devops", "security": "security", "database": "database"} + for tp in top_topics: + kw = eng_kw.get(tp) + if not kw: + continue + for u in c.search_users(kw, limit=10): + login = c.login_of(u) + if login and login not in seen_set: + seen.append(login) + seen_set.add(login) + if len(seen) >= pool_cap: + break + return seen[:pool_cap] + + +def profile_candidate(login: str, repo_contribs: dict[str, dict]) -> dict[str, Any]: + """构建候选人画像:主题向量 + 语言集合 + 活跃度 + 协作开放度。""" + repos = c.user_repos(login, limit=15) + texts = [] + langs: set[str] = set() + fork_count = 0 + for r in repos: + texts.append((r.get("description") or "") + " " + (r.get("identifier") or "")) + if r.get("language") and isinstance(r["language"], dict): + langs.add((r["language"].get("name") or "").lower()) + if r.get("forked_from_project_id") or r.get("forked_count"): + fork_count += 1 + topic_vec: Counter = Counter() + for t in texts: + for tp in T.extract_topics(t): + topic_vec[tp] += 1 + for lg in T.extract_languages(" ".join(texts)): + langs.add(lg) + + activity = 0.4 + if login in repo_contribs: + # 本仓库贡献者 → 高活跃(贡献占比越高加成越大) + activity = 0.7 + 0.3 * min(_parse_ratio(repo_contribs[login].get("contribution_perc")), 1.0) + elif len(repos) >= 5: + activity = 0.7 + elif repos: + activity = 0.4 + collab = min(fork_count / 5.0, 1.0) + + return {"topic_vec": dict(topic_vec), "langs": sorted(langs), + "activity": activity, "collab": collab, "repo_count": len(repos)} + + +def match(owner: str, repo: str, top: int = 10, pool_cap: int = 15, + issue_sample: int = 100) -> dict[str, Any]: + info = c.repo_info(owner, repo) + gap_topics, needed_langs, signals = build_gap_signals(owner, repo, info, issue_sample) + contribs_list = c.contributors(owner, repo) + repo_contribs = {c.login_of(x): x for x in contribs_list if c.login_of(x)} + + # 缺口主题向量(与候选人主题向量同空间) + gap_vec = dict(gap_topics) + + pool = candidate_pool(owner, repo, gap_topics, pool_cap) + scored = [] + for login in pool: + prof = profile_candidate(login, repo_contribs) + topic_overlap = cosine(prof["topic_vec"], gap_vec) + lang_match = jaccard(prof["langs"], needed_langs) if needed_langs else 0.0 + score = (0.45 * topic_overlap + 0.20 * lang_match + + 0.20 * prof["activity"] + 0.15 * prof["collab"]) * 100 + reasons: list[str] = [] + overlap_topics = sorted(set(prof["topic_vec"]) & set(gap_vec), + key=lambda k: -prof["topic_vec"][k]) + if overlap_topics: + reasons.append(f"覆盖缺口主题: {', '.join(overlap_topics[:4])}") + matched_langs = sorted(set(prof["langs"]) & set(needed_langs)) + if matched_langs: + reasons.append(f"语言匹配: {', '.join(matched_langs[:4])}") + if login in repo_contribs: + reasons.append("本仓库活跃贡献者") + if prof["collab"] > 0: + reasons.append(f"协作开放度高(fork={int(prof['collab']*5)})") + activity_level = ("high" if prof["activity"] >= 0.7 + else "medium" if prof["activity"] >= 0.4 else "low") + scored.append({ + "login": login, "score": round(score, 1), + "topic_overlap": round(topic_overlap, 3), + "language_match": round(lang_match, 3), + "activity_level": activity_level, + "repo_languages": prof["langs"][:6], + "repo_count": prof["repo_count"], + "reasons": reasons or ["无明显主题/语言重叠"], + }) + scored.sort(key=lambda x: -x["score"]) + + top_topics = [t for t, _ in gap_topics.most_common(8)] + return { + "scenario": "S4_collaboration_matching", + "repo": f"{owner}/{repo}", + "gap_topics": top_topics, + "needed_languages": needed_langs, + "gap_signals": signals[:30], + "candidates": scored[:top], + "meta": {"pool_size": len(pool), "issue_sample": issue_sample}, + } + + +# --------------------------------------------------------------------------- +# 渲染:Markdown 报告 + Mermaid 协作网络 +# --------------------------------------------------------------------------- + +def render_report(result: dict[str, Any]) -> str: + repo = result["repo"] + cands = result["candidates"] + lines = [ + f"# 科研协作智能匹配报告 — {repo}\n", + f"> 场景 S4 · 子赛题四「应用 GitLink 辅助科研」\n", + "## 一、仓库技术缺口分析\n", + f"- **缺口主题**: {', '.join(result['gap_topics']) or '(未识别到明确主题)'}", + f"- **需求语言**: {', '.join(result['needed_languages']) or '—'}", + f"- **缺口信号样本**: {len(result['gap_signals'])} 条未解决 Issue/PR 主题证据\n", + "| 缺口主题 | 证据(Issue/PR) | 优先级 |", + "|----------|------------------|--------|", + ] + for s in result["gap_signals"][:8]: + lines.append(f"| {s['topic']} | {s['evidence']} | {s.get('priority','')} |") + lines += ["\n## 二、推荐协作伙伴(按综合匹配分排序)\n", + "| 排名 | 用户 | 匹配分 | 主题重叠 | 语言匹配 | 活跃度 | 匹配理由 |", + "|------|------|--------|----------|----------|--------|----------|"] + for i, m in enumerate(cands, 1): + lines.append(f"| {i} | `{m['login']}` | {m['score']} | {m['topic_overlap']} | " + f"{m['language_match']} | {m['activity_level']} | {'; '.join(m['reasons'][:2])} |") + lines.append(f"\n_候选池规模 {result['meta']['pool_size']},issue 采样 {result['meta']['issue_sample']}_\n") + return "\n".join(lines) + + +def render_mermaid(result: dict[str, Any]) -> str: + repo = result["repo"].replace("/", "_") + lines = ["```mermaid", "graph TD", f' R["{result["repo"]}
(目标仓库)"]'] + for i, m in enumerate(result["candidates"][:8], 1): + nid = f"C{i}" + lines.append(f' {nid}["{m["login"]}
{m["score"]}分"]') + # 边的粗细用文字标签近似 + lines.append(f' R -- "{m["topic_overlap"]}" --> {nid}') + lines.append("```") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S4 科研协作智能匹配") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--top", type=int, default=10) + ap.add_argument("--pool", type=int, default=15, help="候选池上限") + ap.add_argument("--issue-sample", type=int, default=100) + ap.add_argument("--out", help="输出目录(写 match.json/report.md/network.mmd);省略则打印 JSON") + args = ap.parse_args() + + result = match(args.owner, args.repo, top=args.top, pool_cap=args.pool, + issue_sample=args.issue_sample) + + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "match.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + with open(os.path.join(args.out, "network.mmd"), "w", encoding="utf-8") as f: + f.write(render_mermaid(result)) + print(f"✓ S4 匹配完成 → {args.out}/match.json | report.md | network.mmd") + print(f" 缺口主题: {', '.join(result['gap_topics'])}") + print(f" Top 推荐: {', '.join(m['login']+'('+str(m['score'])+')' for m in result['candidates'][:5])}") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/profile.py b/scripts/research/profile.py new file mode 100644 index 0000000..b399881 --- /dev/null +++ b/scripts/research/profile.py @@ -0,0 +1,205 @@ +"""profile.py — 主体画像(Pillar 2)。 + +模式(三选一): + --login 学者画像(主题向量/语言/活跃度/协作开放度 + 基本信息) + --owner O --repo R 项目画像(仓库元数据/贡献者/主题/语言/研究维度评分) + --category <域> --top N 领域核心学者(从热点榜 core_scholars 取 top-N 逐个画像) + +产物:profile.json + report.md(画像卡片)。 +复用:match.profile_candidate / collect.user_info/user_repos/contributors/repo_info / topics。 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c +import topics as T +import match as M # 复用 profile_candidate +import hotspot as H # --category 模式取 core_scholars + +_BOT_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot") + + +def _is_bot(login: str) -> bool: + low = login.lower() + return any(h in low for h in _BOT_HINTS) + + +# --------------------------------------------------------------------------- +# 学者画像 +# --------------------------------------------------------------------------- + +def scholar_profile(login: str) -> dict: + """学者画像:复用 match.profile_candidate(topic_vec/langs/activity/collab)+ user_info。""" + info = c.user_info(login) or {} + prof = M.profile_candidate(login, {}) + topic_vec = prof.get("topic_vec", {}) or {} + topics_top = sorted(topic_vec.items(), key=lambda x: -x[1])[:8] + return { + "type": "scholar", + "login": login, + "name": info.get("name") or info.get("username") or login, + "bio": info.get("bio") or info.get("description") or "", + "topic_vec": topic_vec, + "top_topics": [{"topic": t, "count": n} for t, n in topics_top], + "languages": prof.get("langs", []), + "activity": round(prof.get("activity", 0), 2), + "collab": round(prof.get("collab", 0), 2), + "repo_count": prof.get("repo_count", 0), + } + + +# --------------------------------------------------------------------------- +# 项目画像 +# --------------------------------------------------------------------------- + +def project_profile(owner: str, repo: str) -> dict: + """项目画像:仓库元数据 + 贡献者 + 主题/语言 + 研究维度评分(0–40)。""" + info = c.repo_info(owner, repo) or {} + contribs = c.contributors(owner, repo) or [] + readme = (c.readme(owner, repo) or "")[:4000] + langs = c.languages(owner, repo) or {} + desc = info.get("description") or "" + + tc = T.topic_counter([desc, readme]) + topics_top = [{"topic": t, "count": n} for t, n in tc.most_common(8)] + + stars = c.as_int(info.get("praises_count") or info.get("watchers_count") or 0) + forks = c.as_int(info.get("forked_count") or 0) + visits = c.as_int(info.get("visits") or 0) + score = { + "doc": 5 if readme else 0, + "license": 5 if info.get("license") else 0, + "collab": min(len(contribs), 10) * 1, # 贡献者(封顶10) + "impact": min(stars + forks, 10) * 2, # star+fork(封顶20) + } + logins = [c.login_of(x) for x in contribs] + logins = [l for l in logins if l and not _is_bot(l)][:8] + + return { + "type": "project", + "repo": f"{owner}/{repo}", + "name": info.get("name") or repo, + "description": desc, + "topics": topics_top, + "languages": (list(langs.keys()) if isinstance(langs, dict) else list(langs))[:8], + "stars": stars, "forks": forks, "visits": visits, + "contributors_count": len(contribs), + "top_contributors": logins, + "score": score, + "score_total": sum(score.values()), + } + + +# --------------------------------------------------------------------------- +# 领域核心学者 +# --------------------------------------------------------------------------- + +def category_scholars(category: str, top: int = 5, limit: int = 20) -> list[dict]: + """领域核心学者:热点榜 core_scholars 取 top-N,逐个画像。""" + raw = H.collect(category=category, limit=limit) + scholars = (H.compute(raw).get("core_scholars") or [])[:top] + out = [] + for s in scholars: + login = s.get("login") + if not login: + continue + prof = scholar_profile(login) + prof["category_repos"] = s.get("repo_count") + out.append(prof) + return out + + +# --------------------------------------------------------------------------- +# 报告 +# --------------------------------------------------------------------------- + +def render_report(result: dict) -> str: + lines = ["# 🪪 主体画像报告", ""] + + def _scholar_card(p: dict, depth: int = 2): + h = "#" * depth + lines.append(f"{h} 👤 {p.get('name')} (`{p.get('login')}`)") + if p.get("bio"): + lines.append(f"> {p['bio'][:120]}") + lines.append("") + lines.append(f"- 活跃度 **{p.get('activity')}** · 协作开放度 **{p.get('collab')}** · 公开仓库 **{p.get('repo_count')}**") + if p.get("category_repos") is not None: + lines.append(f"- 该领域关联仓库 **{p.get('category_repos')}**") + if p.get("top_topics"): + lines.append("- 研究主题:" + "、".join(f"`{t['topic']}`({t['count']})" for t in p["top_topics"][:6])) + if p.get("languages"): + lines.append("- 语言:" + "、".join(p["languages"][:6])) + lines.append("") + + def _project_card(p: dict): + lines.append(f"## 📦 {p.get('name')} (`{p.get('repo')}`)") + if p.get("description"): + lines.append(f"> {p['description'][:140]}") + lines.append("") + lines.append(f"- ★{p.get('stars')} ⑂{p.get('forks')} 👁{p.get('visits')} · 贡献者 {p.get('contributors_count')} · 研究维度评分 **{p.get('score_total')}/40**") + if p.get("topics"): + lines.append("- 主题:" + "、".join(f"`{t['topic']}`" for t in p["topics"][:6])) + if p.get("top_contributors"): + lines.append("- 核心贡献者:" + "、".join(f"`{l}`" for l in p["top_contributors"][:6])) + lines.append("") + + profiles = result.get("profiles") or [] + if result.get("mode") == "project": + _project_card(profiles[0] if profiles else {}) + else: + for p in profiles: + _scholar_card(p) + lines.append("---\n*由 gitlink-research-profile 生成*") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description="主体画像 — 学者 / 项目 / 领域核心学者") + ap.add_argument("--login", default="", help="学者 login(学者画像)") + ap.add_argument("--owner", default="", help="仓库 owner(项目画像)") + ap.add_argument("--repo", default="", help="仓库名(项目画像)") + ap.add_argument("--category", default="", help="领域分类(领域核心学者)") + ap.add_argument("--top", type=int, default=5, help="领域模式取 top-N 学者(默认 5)") + ap.add_argument("--limit", type=int, default=20, help="领域模式热点榜上限(默认 20)") + ap.add_argument("--out", "-o", default="", help="输出目录(不传则打印 JSON)") + args = ap.parse_args() + + if args.login: + profiles = [scholar_profile(args.login)] + mode = "scholar" + elif args.owner and args.repo: + profiles = [project_profile(args.owner, args.repo)] + mode = "project" + elif args.category: + profiles = category_scholars(args.category, top=args.top, limit=args.limit) + mode = "category_scholars" + else: + print(json.dumps({"ok": False, "error": "need --login OR --owner/--repo OR --category"}, + ensure_ascii=False)) + sys.exit(1) + + result = {"scenario": "profile", "mode": mode, "profiles": profiles} + + json_text = json.dumps(result, ensure_ascii=False, indent=2) + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "profile.json"), "w", encoding="utf-8") as f: + f.write(json_text) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + sys.stderr.write(f"[profile] ✓ mode={mode} 产物落 {args.out}\n") + else: + print(json_text) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/report.py b/scripts/research/report.py new file mode 100644 index 0000000..c774ab1 --- /dev/null +++ b/scripts/research/report.py @@ -0,0 +1,555 @@ +"""report.py — S5 科研进度智能跟踪与预警。 + +输入一个科研仓库,统计「本周 / 上周」的提交、Issue、PR 活跃度,结合里程碑进度, +用阈值规则产出风险预警(stale issue / stale PR / 逾期里程碑 / 低活跃 / bus_factor), +并给出本周相对上周的 commit 趋势。辅助科研负责人及时发现「项目停滞 / 单点风险」。 + +数据全部经 gitlink-cli 获取(commits / issue +list / pr +list / milestone +list / +repo +contributors)。算法为纯函数、零第三方依赖,单测以 mock 数据喂入。 + +用法: + python report.py --owner mindspore-Ecosystem --repo mindspore --out ./out + python report.py --owner O --repo R # 仅打印 JSON +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import Counter +from datetime import datetime, timedelta, timezone +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 + +# --------------------------------------------------------------------------- +# 常量(阈值,便于单测覆盖) +# --------------------------------------------------------------------------- + +STALE_ISSUE_DAYS = 30 # 开放且无活动 > 30 天 → stale issue +STALE_PR_DAYS = 14 # 开放且未 review > 14 天 → stale PR +STALE_PR_WINDOW_DAYS = 90 # 统计 stale PR 时回溯窗口(避免扫全量历史) +LOW_ACTIVITY_COMMITS = 3 # 本周提交 < 3 → 低活跃 +BUS_FACTOR_RATIO = 0.5 # 单一贡献者占本周提交 > 50% → bus factor + +UTC = timezone.utc + + +# --------------------------------------------------------------------------- +# 时间解析 +# --------------------------------------------------------------------------- + +def parse_time(s: Any) -> datetime | None: + """把时间字段解析为带 UTC 时区的 datetime;无法解析返回 None。 + + 兼容两类输入: + - ISO 字串,如 '2024-06-01T08:30:00+08:00' / '2024-06-01T08:30:00Z' + / '2024-06-01 08:30:00' / '2024-06-01' + - 整数(或整数字串)秒级 Unix 时间戳,如 1717200000 / '1717200000' + """ + if s is None: + return None + if isinstance(s, (int, float)): + try: + return datetime.fromtimestamp(float(s), tz=UTC) + except (OverflowError, OSError, ValueError): + return None + if not isinstance(s, str): + return None + text = s.strip() + if not text: + return None + # 纯数字 → 当作 Unix 秒级时间戳 + if text.lstrip("-").isdigit(): + try: + return datetime.fromtimestamp(float(text), tz=UTC) + except (OverflowError, OSError, ValueError): + return None + # ISO 字串:统一以 Z → +00:00 + iso = text.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(iso) + except ValueError: + # 尝试 'YYYY-MM-DD HH:MM:SS' / 'YYYY-MM-DD' + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + dt = datetime.strptime(text, fmt) + break + except ValueError: + continue + else: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def in_window(dt: datetime | None, days: int, now: datetime) -> bool: + """判断 dt 是否落在 [now - days, now] 区间内。None 视为不在窗口内。""" + if dt is None: + return False + if days < 0: + return False + now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC) + dt_utc = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) + return (now_utc - dt_utc) <= timedelta(days=days) and (now_utc - dt_utc) >= timedelta(0) + + +# --------------------------------------------------------------------------- +# 周统计 +# --------------------------------------------------------------------------- + +def _commit_time(item: dict) -> datetime | None: + for key in ("timestamp", "commit_time", "committed_date", "created_at"): + v = item.get(key) + dt = parse_time(v) + if dt is not None: + return dt + return None + + +def _issue_time(item: dict, prefer: tuple[str, ...]) -> datetime | None: + for key in prefer: + v = item.get(key) + dt = parse_time(v) + if dt is not None: + return dt + return None + + +def _issue_status(item: dict) -> str: + s = item.get("status") + if isinstance(s, str) and s: + return s.lower() + return "" + + +def _pr_status(item: dict) -> int: + """PR 状态 → 0=open,1=merged,2=closed。 + + GitLink PR 列表 status 是字符串('merged'/'open'/'closed'); + 详情/health 可能是 pull_request_status 整数(0/1/2)。两者兼容。 + """ + v = item.get("status", item.get("pull_request_status", 0)) + if isinstance(v, str): + s = v.lower() + if "merged" in s: + return 1 + if s in ("closed", "close", "reject", "rejected"): + return 2 + return 0 + try: + return int(v) + except (TypeError, ValueError): + return 0 + + +def _author_login(item: dict, key: str = "author") -> str: + """从 commit/issue/pr 的 author 子对象取 login;退化用顶层 login/name。""" + obj = item.get(key) + if isinstance(obj, dict): + for k in ("login", "name", "username"): + if obj.get(k): + return str(obj[k]) + for k in ("login", "name", "username", "committer_login"): + if item.get(k): + return str(item[k]) + return "" + + +def _week_bounds(now: datetime) -> tuple[datetime, datetime, datetime, datetime]: + """返回 (this_week_start, last_week_start, last_week_end, now)(UTC)。 + + 「周」按自然天对齐:本周 = [now-7d, now],上周 = [now-14d, now-7d)。 + """ + now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC) + this_start = now_utc - timedelta(days=7) + last_start = now_utc - timedelta(days=14) + last_end = this_start + return this_start, last_start, last_end, now_utc + + +def _commits_in(commits: list[dict], lo: datetime, hi: datetime) -> list[dict]: + out = [] + for it in commits: + dt = _commit_time(it) + if dt is None: + continue + if lo <= dt < hi: + out.append(it) + return out + + +def _window_summary(commits: list, issues: list, prs: list, + contributors: list, lo: datetime, hi: datetime, + now: datetime) -> dict[str, Any]: + """统计 [lo, hi) 区间内的活动摘要(供 week_stats 复用)。""" + w_commits = _commits_in(commits, lo, hi) + + opened = closed = stale_open = 0 + for iss in issues: + created = _issue_time(iss, ("created_at", "created_unix")) + if created is not None and lo <= created < hi: + opened += 1 + st = _issue_status(iss) + if st in ("closed", "reject", "rejected"): + # 关闭时间优先 closed_at/journals_updated_at + closed_dt = _issue_time(iss, ("closed_at", "journals_updated_at", "updated_at")) + if closed_dt is not None and lo <= closed_dt < hi: + closed += 1 + elif st in ("", "open", "opened"): + # stale 判定:开放且距今 > STALE_ISSUE_DAYS 无活动 + last_dt = _issue_time(iss, ("journals_updated_at", "updated_at", "created_at")) + if last_dt is not None and (now - last_dt) > timedelta(days=STALE_ISSUE_DAYS): + stale_open += 1 + + pr_opened = pr_merged = pr_open_stale = 0 + for pr in prs: + created = _issue_time(pr, ("pr_created_unix", "created_at", "created_unix")) + if created is None: + created = _commit_time(pr) + if created is not None and lo <= created < hi: + pr_opened += 1 + st = _pr_status(pr) + if st == 1: # merged + merged_dt = _issue_time(pr, ("pr_merged_unix", "merged_at", "updated_at")) + if merged_dt is None: + merged_dt = created + if merged_dt is not None and lo <= merged_dt < hi: + pr_merged += 1 + elif st == 0: # open + # stale PR:开放且 created > STALE_PR_DAYS 天前、近 STALE_PR_WINDOW 天内 + if created is not None and (now - created) > timedelta(days=STALE_PR_DAYS) \ + and created > now - timedelta(days=STALE_PR_WINDOW_DAYS): + pr_open_stale += 1 + + # 本周活跃贡献者:去重 author login(commits) + logins = [_author_login(it) for it in w_commits] + contrib_active = {lg for lg in logins if lg} + return { + "commits": len(w_commits), + "issues_opened": opened, + "issues_closed": closed, + "issues_stale": stale_open, + "prs_opened": pr_opened, + "prs_merged": pr_merged, + "prs_open_stale": pr_open_stale, + "contributors_active": len(contrib_active), + "contributors_active_logins": sorted(contrib_active), + } + + +def week_stats(commits: list, issues: list, prs: list, + contributors: list, now: datetime) -> dict[str, Any]: + """统计本周 / 上周活跃度摘要。contributors 形参保留以兼容,活跃度以 commits 的作者为准。""" + this_start, last_start, last_end, now_utc = _week_bounds(now) + this = _window_summary(commits, issues, prs, contributors, + this_start, now_utc, now_utc) + last = _window_summary(commits, issues, prs, contributors, + last_start, last_end, now_utc) + return { + "this_week": this, + "last_week": last, + "window": { + "this_week_start": this_start.isoformat(), + "now": now_utc.isoformat(), + "last_week_start": last_start.isoformat(), + "last_week_end": last_end.isoformat(), + }, + "total_contributors": len(contributors), + } + + +# --------------------------------------------------------------------------- +# 里程碑进度 +# --------------------------------------------------------------------------- + +def _milestone_due(item: dict) -> datetime | None: + for key in ("effective_date", "due_date", "deadline", "end_date"): + dt = parse_time(item.get(key)) + if dt is not None: + return dt + return None + + +def milestone_progress(milestones: list, issues: list, now: datetime) -> list[dict]: + """每个里程碑的 open/closed issue 数、完成率、是否逾期。 + + Issue → Milestone 的关联字段优先用 milestone_name / milestone_id。 + """ + now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC) + by_key: dict[Any, dict[str, int]] = {} + for iss in issues: + ms_name = iss.get("milestone_name") or iss.get("milestone") + ms_id = iss.get("milestone_id") or iss.get("milestone_index") + key = ms_name if ms_name else (ms_id if ms_id is not None else None) + if key is None: + continue + bucket = by_key.setdefault(key, {"open": 0, "closed": 0}) + st = _issue_status(iss) + if st in ("closed", "reject", "rejected"): + bucket["closed"] += 1 + else: + bucket["open"] += 1 + + out: list[dict] = [] + for ms in milestones: + name = ms.get("name") or ms.get("title") or "(未命名)" + key = name + counts = by_key.get(key, {"open": 0, "closed": 0}) + total = counts["open"] + counts["closed"] + pct = round(100.0 * counts["closed"] / total, 1) if total else 0.0 + due = _milestone_due(ms) + overdue = False + # 仅当里程碑未关闭 + 有 due_date 且 due < now → 逾期 + ms_status = str(ms.get("status", "")).lower() + is_closed = ms_status in ("closed", "reject", "rejected", "done", "completed") + if due is not None and not is_closed and due < now_utc: + overdue = True + out.append({ + "name": name, + "open": counts["open"], + "closed": counts["closed"], + "total": total, + "completion_pct": pct, + "due_date": due.isoformat() if due else None, + "overdue": overdue, + "status": ms.get("status", ""), + }) + return out + + +# --------------------------------------------------------------------------- +# 趋势 +# --------------------------------------------------------------------------- + +def trend(this_week: dict, last_week: dict) -> dict[str, Any]: + """本周 vs 上周 commit 增量与活跃度等级。""" + tc = this_week.get("commits", 0) + lc = last_week.get("commits", 0) + if lc == 0: + delta_pct = 100.0 if tc > 0 else 0.0 + else: + delta_pct = round(100.0 * (tc - lc) / lc, 1) + if delta_pct > 10: + level = "increasing" + elif delta_pct < -10: + level = "decreasing" + else: + level = "stable" + return {"commit_delta_pct": delta_pct, "activity_level": level, + "this_week_commits": tc, "last_week_commits": lc} + + +# --------------------------------------------------------------------------- +# 风险预警 +# --------------------------------------------------------------------------- + +def risk_warnings(stats: dict, milestones: list, contributors: list, + commits: list | None = None, now: datetime | None = None) -> list[dict]: + """阈值规则 → 风险列表。 + + 依赖 week_stats 产出的 stats(含 this_week / last_week)以及 milestone_progress + 的 milestones 列表。commits 用于 bus_factor 复算(可选,避免 stats 内无明细)。 + """ + now_utc = (now or datetime.now(UTC)).astimezone(UTC) \ + if (now or datetime.now(UTC)).tzinfo else (now or datetime.now(UTC)).replace(tzinfo=UTC) + this_start = now_utc - timedelta(days=7) + warnings: list[dict] = [] + tw: dict = stats.get("this_week", {}) + + # 1) 低活跃 + commits_this = tw.get("commits", 0) + if commits_this < LOW_ACTIVITY_COMMITS: + warnings.append({ + "level": "warning", "type": "low_activity", + "message": f"本周提交仅 {commits_this} 次(低于阈值 {LOW_ACTIVITY_COMMITS}),项目可能进展缓慢", + "metric": commits_this, "suggestion": "确认是否进入收尾阶段;若无,组织一次进度同步。", + }) + + # 2) bus_factor:单一贡献者本周提交占比 > 50% + if commits: + w_commits = _commits_in(commits, this_start, now_utc) + else: + w_commits = [] # 无明细 → 无法判 bus factor + if w_commits: + login_counts: Counter = Counter(_author_login(it) for it in w_commits) + top_login, top_n = login_counts.most_common(1)[0] + ratio = top_n / len(w_commits) + active_n = len({lg for lg in login_counts if lg}) + if ratio > BUS_FACTOR_RATIO and active_n <= 2: + warnings.append({ + "level": "critical", "type": "bus_factor", + "message": f"bus factor 风险:{top_login or '(匿名)'} 一人贡献本周 {top_n}/{len(w_commits)} " + f"次提交({round(ratio*100)}%),活跃贡献者仅 {active_n} 人", + "metric": round(ratio, 3), "suggestion": "引入第二贡献者 / 文档化核心模块,降低单点依赖。", + }) + + # 3) stale issue / stale PR(沿用 week_stats 已统计的口径) + stale_iss = tw.get("issues_stale", 0) + if stale_iss >= 5: + warnings.append({ + "level": "warning" if stale_iss < 20 else "critical", + "type": "stale_issue", + "message": f"存在 {stale_iss} 个开放 Issue 超过 {STALE_ISSUE_DAYS} 天无活动", + "metric": stale_iss, "suggestion": "分诊:关闭无效 Issue、分配负责人或拆解。", + }) + stale_pr = tw.get("prs_open_stale", 0) + if stale_pr >= 1: + warnings.append({ + "level": "warning" if stale_pr < 3 else "critical", + "type": "stale_pr", + "message": f"存在 {stale_pr} 个开放 PR 超过 {STALE_PR_DAYS} 天未 review/合并", + "metric": stale_pr, "suggestion": "安排 review 或明确 reject,避免 PR 堆积。", + }) + + # 4) 逾期里程碑 + for ms in milestones: + if isinstance(ms, dict) and ms.get("overdue"): + warnings.append({ + "level": "critical", "type": "overdue_milestone", + "message": f"里程碑「{ms.get('name', '?')}」已逾期" + + (f"(due {ms.get('due_date')})" if ms.get("due_date") else "") + + f",完成率 {ms.get('completion_pct', 0)}%", + "metric": ms.get("completion_pct", 0), + "suggestion": "重新评估范围或顺延 deadline,并同步干系人。", + }) + + # 排序:critical > warning > info + rank = {"critical": 0, "warning": 1, "info": 2} + warnings.sort(key=lambda w: (rank.get(w["level"], 9), w["type"])) + return warnings + + +# --------------------------------------------------------------------------- +# 主入口(取数 + 算法) +# --------------------------------------------------------------------------- + +def analyze(owner: str, repo: str, now: datetime | None = None) -> dict[str, Any]: + """取数 + 算法:返回完整结果 dict。""" + if now is None: + now = datetime.now(UTC) + commits = c.commits(owner, repo, max_pages=10) + issues = c.issues_all(owner, repo) + prs = c.prs_all(owner, repo) + milestones = c.milestones(owner, repo) + contributors = c.contributors(owner, repo) + + stats = week_stats(commits, issues, prs, contributors, now) + ms_progress = milestone_progress(milestones, issues, now) + warnings = risk_warnings(stats, ms_progress, contributors, commits=commits, now=now) + tr = trend(stats["this_week"], stats["last_week"]) + + return { + "scenario": "S5_progress_tracking", + "repo": f"{owner}/{repo}", + "generated_at": now.astimezone(UTC).isoformat(), + "week_stats": stats, + "trend": tr, + "milestones": ms_progress, + "risk_warnings": warnings, + "meta": { + "commits_fetched": len(commits), + "issues_fetched": len(issues), + "prs_fetched": len(prs), + "milestones_fetched": len(milestones), + "contributors_fetched": len(contributors), + }, + } + + +# --------------------------------------------------------------------------- +# 渲染:Markdown 周报 +# --------------------------------------------------------------------------- + +def render_report(result: dict[str, Any]) -> str: + repo = result["repo"] + stats = result["week_stats"] + tw, lw = stats["this_week"], stats["last_week"] + tr = result["trend"] + lines = [ + f"# 科研进度智能跟踪周报 — {repo}\n", + f"> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 {result['generated_at']}\n", + "## 一、本周 / 上周活动对比\n", + "| 指标 | 本周 | 上周 |", + "|------|------|------|", + f"| 提交 commits | {tw['commits']} | {lw['commits']} |", + f"| Issue 新增 | {tw['issues_opened']} | {lw['issues_opened']} |", + f"| Issue 关闭 | {tw['issues_closed']} | {lw['issues_closed']} |", + f"| 开放 stale issue (>{STALE_ISSUE_DAYS}天) | {tw['issues_stale']} | {lw['issues_stale']} |", + f"| PR 新增 | {tw['prs_opened']} | {lw['prs_opened']} |", + f"| PR 合并 | {tw['prs_merged']} | {lw['prs_merged']} |", + f"| 开放 stale PR (>{STALE_PR_DAYS}天) | {tw['prs_open_stale']} | {lw['prs_open_stale']} |", + f"| 活跃贡献者 | {tw['contributors_active']} | {lw['contributors_active']} |", + "", + f"- **趋势**:commit 周环比 **{tr['commit_delta_pct']}%**,活跃度等级 `{tr['activity_level']}`", + "", + "## 二、里程碑进度\n", + ] + ms = result["milestones"] + if ms: + lines += [ + "| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 |", + "|--------|-----------|--------|----------|------|", + ] + for m in ms: + flag = " ⚠️逾期" if m["overdue"] else "" + lines.append( + f"| {m['name']}{flag} | {m['closed']}/{m['total']} | {m['completion_pct']}% " + f"| {m['due_date'] or '—'} | {m['status'] or '—'} |" + ) + else: + lines.append("_(仓库无里程碑数据)_") + + lines += ["\n## 三、风险预警\n"] + warns = result["risk_warnings"] + if warns: + lines += ["| 级别 | 类型 | 说明 | 建议 |", "|------|------|------|------|"] + for w in warns: + lines.append(f"| {w['level']} | {w['type']} | {w['message']} | {w['suggestion']} |") + else: + lines.append("_(未触发风险阈值,进度正常)_") + + lines += [ + "\n## 四、附\n", + f"- 取数:commits={result['meta']['commits_fetched']} " + f"issues={result['meta']['issues_fetched']} prs={result['meta']['prs_fetched']} " + f"milestones={result['meta']['milestones_fetched']} " + f"contributors={result['meta']['contributors_fetched']}", + f"- 窗口:本周 [{stats['window']['this_week_start']}, {stats['window']['now']}];" + f"上周 [{stats['window']['last_week_start']}, {stats['window']['last_week_end']})", + f"- 阈值:stale_issue>{STALE_ISSUE_DAYS}天 / stale_pr>{STALE_PR_DAYS}天 / " + f"低活跃<{LOW_ACTIVITY_COMMITS}次/周 / bus_factor>{int(BUS_FACTOR_RATIO*100)}%", + "", + ] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S5 科研进度智能跟踪与预警") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--out", help="输出目录(写 report.json + weekly_report.md);省略则打印 JSON") + args = ap.parse_args() + + result = analyze(args.owner, args.repo) + + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "report.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "weekly_report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + print(f"✓ S5 周报完成 → {args.out}/report.json | weekly_report.md") + print(f" 本周提交 {result['week_stats']['this_week']['commits']} " + f"(趋势 {result['trend']['activity_level']});风险 {len(result['risk_warnings'])} 条") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/repro.py b/scripts/research/repro.py new file mode 100644 index 0000000..43bb7d7 --- /dev/null +++ b/scripts/research/repro.py @@ -0,0 +1,573 @@ +"""repro.py — S3 科研项目合规与复现性检查。 + +输入一个科研仓库,检查其「合规性」(许可证、版权、依赖、安全策略、数据隐私) +与「可复现性」(CI 配置、lockfile、README 是否含数据集/环境/构建说明、版本 tag、 +密钥泄露),分别给出 0-10 的复现分与合规分,并产出检查清单、风险项与中文报告。 + +数据全部经 gitlink-cli 获取: + - repo +info(仓库信息、版本 tag) + - file +get(LICENSE / README / go.mod / requirements.txt / package.json / .gitignore 等) + - repo +tree(扫 data/、.env、config、.gitea/.github workflows 等是否存在) + +用法: + python repro.py --owner mindspore-Ecosystem --repo mindspore --out ./out + python repro.py --owner O --repo R # 仅打印 JSON +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 +import gitlink_data as gd # noqa: E402 + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- + +# 复现性/合规相关的关键文件(相对仓库根) +KEY_FILES: tuple[str, ...] = ( + "LICENSE", "LICENSE.txt", "LICENSE.md", + "README", "README.md", "README.rst", + "go.mod", "requirements.txt", "package.json", "Cargo.toml", + ".gitignore", "SECURITY.md", "CONTRIBUTING.md", +) + +# CI 配置目录/文件(复现性信号)。 +# 注意:根 tree 通常只列顶层目录(.github/.devops/.gitea),不一定展开到 workflows/, +# 故同时收录顶层目录名与深层路径;.devops 是 GitLink 专属 CI/CD 目录。 +CI_PATHS: tuple[str, ...] = ( + ".devops", + ".gitea", ".gitea/workflows", + ".github", ".github/workflows", + ".gitlab-ci.yml", ".circleci", ".travis.yml", "azure-pipelines.yml", +) + +# 锁文件(复现性信号:依赖版本固定) +LOCKFILES: tuple[str, ...] = ( + "go.sum", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "requirements.txt", "poetry.lock", "Pipfile.lock", "Cargo.lock", "composer.lock", +) + +# 敏感目录/文件(数据隐私信号) +SENSITIVE_PATHS: tuple[str, ...] = ( + "data/", "data", "dataset/", "datasets/", ".env", ".env.local", + "config/secrets", "secrets", +) + +# README 复现性关键词(数据集 / 环境 / 构建 / 运行说明) +README_REPRO_KEYWORDS: tuple[str, ...] = ( + "install", "setup", "环境", "依赖", "build", "构建", "运行", "run", + "dataset", "数据集", "docker", "conda", "pip install", "npm install", + "requirements", "reproduce", "复现", "环境配置", "usage", "用法", +) + +# 许可证识别关键词(顺序即优先级) +LICENSE_PATTERNS: tuple[tuple[str, str], ...] = ( + ("MulanPSL", "MulanPSL-2.0"), + ("木兰宽松许可证", "MulanPSL-2.0"), + ("Apache License", "Apache-2.0"), + ("MIT License", "MIT"), + ("GNU GENERAL PUBLIC LICENSE", "GPL"), + ("GNU Lesser General Public License", "LGPL"), + ("BSD ", "BSD"), + ("ISC License", "ISC"), + ("Mozilla Public License", "MPL"), + ("Unlicense", "Unlicense"), +) + +# 密钥/敏感信息正则(按类别) +SECRET_PATTERNS: tuple[tuple[str, str, str], ...] = ( + # (category, level, regex) + ("private_key", "critical", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"), + ("aws_access_key", "critical", r"AKIA[0-9A-Z]{16}"), + ("aws_secret", "critical", r"aws_secret_access_key\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}"), + ("generic_api_key", "high", r"(?i)api[_-]?key\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}"), + ("google_api_key", "high", r"AIza[0-9A-Za-z_\-]{35}"), + ("slack_token", "high", r"xox[baprs]-[0-9A-Za-z-]{10,}"), + ("github_token", "high", r"gh[pousr]_[A-Za-z0-9]{36,}"), + ("jwt", "medium", r"eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), + ("email", "low", r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[A-Za-z]{2,}"), + # 中国大陆手机号 + ("phone_cn", "low", r"(? dict[str, Any]: + """关键词匹配 LICENSE 文本,返回许可证信息。 + + 返回: {"license": str, "recognized": bool, "evidence": str} + 未识别返回 license="None"。 + """ + if not text or not text.strip(): + return {"license": "None", "recognized": False, + "evidence": "LICENSE 文件为空或缺失"} + low = text.lower() + for pat, name in LICENSE_PATTERNS: + if pat.lower() in low: + # 找到关键词所在行作为证据 + idx = low.find(pat.lower()) + line_start = text.rfind("\n", 0, idx) + 1 + line_end = text.find("\n", idx) + if line_end == -1: + line_end = len(text) + evidence = text[line_start:line_end].strip()[:120] + return {"license": name, "recognized": True, "evidence": evidence} + return {"license": "None", "recognized": False, + "evidence": "未匹配到已知许可证关键词"} + + +def scan_secrets(text: str, file: str = "") -> list[dict[str, Any]]: + """扫描文本中的密钥/敏感信息。 + + 返回: [{"level","category","file","line","detail"}, ...] + """ + if not text: + return [] + findings: list[dict[str, Any]] = [] + lines = text.splitlines() + for category, level, pattern in SECRET_PATTERNS: + for m in re.finditer(pattern, text): + # 计算行号与所在行内容 + line_no = text.count("\n", 0, m.start()) + 1 + line_content = lines[line_no - 1] if 0 <= line_no - 1 < len(lines) else "" + detail = m.group(0) + # 脱敏:长串截断 + if len(detail) > 40: + detail = detail[:20] + "..." + detail[-6:] + findings.append({ + "level": level, "category": category, "file": file, + "line": line_no, "detail": detail, + "context": line_content.strip()[:80], + }) + return findings + + +def _tree_paths(tree: list) -> list[str]: + """从 tree 列表提取所有路径字符串(兼容多种字段名)。""" + paths: list[str] = [] + for item in tree: + if isinstance(item, dict): + for k in ("path", "name", "filepath"): + v = item.get(k) + if isinstance(v, str) and v: + paths.append(v) + break + elif isinstance(item, str) and item: + paths.append(item) + return paths + + +def _has_path(paths: list[str], targets: tuple[str, ...]) -> list[str]: + """paths 中命中任一 target(前缀/精确都算)的命中项,返回命中原文(去重保序)。""" + hits: list[str] = [] + seen: set[str] = set() + low_targets = [t.lower().rstrip("/") for t in targets] + for p in paths: + pl = p.lower().rstrip("/") + for tl in low_targets: + if pl == tl or pl.startswith(tl + "/"): + key = f"{p}::{tl}" + if key not in seen: + seen.add(key) + hits.append(p) + break + return hits + + +def repro_checks(file_texts: dict[str, str], tree: list, + repo_info: dict | None = None) -> list[dict[str, Any]]: + """复现性检查。 + + file_texts: {路径: 文本内容}(已取好) + tree: tree 列表(已取好) + repo_info: 仓库信息(取版本 tag;无 tag 字段则 unknown) + + 返回检查项列表: [{"name","pass","score","evidence"}] + 每项 score 0-2(0=缺失/失败, 1=部分, 2=完备)。 + """ + paths = _tree_paths(tree) + items: list[dict[str, Any]] = [] + + # 1) CI 配置 + ci_hits = _has_path(paths, CI_PATHS) + if ci_hits: + items.append({"name": "CI 配置", "pass": True, "score": 2, + "evidence": f"检测到 CI 配置: {', '.join(ci_hits[:3])}"}) + else: + items.append({"name": "CI 配置", "pass": False, "score": 0, + "evidence": "未找到 .gitea/.github/.gitlab 等 CI 配置"}) + + # 2) lockfile(依赖版本固定) + lock_hits = _has_path(paths, LOCKFILES) + if lock_hits: + items.append({"name": "依赖锁文件", "pass": True, "score": 2, + "evidence": f"存在 lockfile: {', '.join(lock_hits[:3])}"}) + else: + items.append({"name": "依赖锁文件", "pass": False, "score": 0, + "evidence": "未找到 go.sum/package-lock.json/poetry.lock 等锁文件"}) + + # 3) README 含数据集/环境/构建说明 + readme_text = "" + for k in ("README.md", "README", "README.rst"): + if k in file_texts and file_texts[k]: + readme_text = file_texts[k] + break + if readme_text: + low = readme_text.lower() + hit_kw = [kw for kw in README_REPRO_KEYWORDS if kw.lower() in low] + score = 2 if len(hit_kw) >= 4 else (1 if len(hit_kw) >= 1 else 0) + items.append({"name": "README 复现说明", "pass": score > 0, "score": score, + "evidence": (f"README 含复现关键词 {len(hit_kw)} 个: {', '.join(hit_kw[:5])}" + if hit_kw else "README 存在但缺少数据集/环境/构建说明")}) + else: + items.append({"name": "README 复现说明", "pass": False, "score": 0, + "evidence": "未找到 README"}) + + # 4) 版本 tag(用 repo_info,无 tag 字段则 unknown) + info = repo_info or {} + tag = (info.get("version") or info.get("tag") or info.get("release_tag") + or info.get("default_branch") or "") + # 多数 GitLink repo_info 无显式 tag 字段 → 标 unknown(不扣分但提示) + has_tag = bool(info.get("version") or info.get("tag") or info.get("release_tag")) + if has_tag: + items.append({"name": "版本 tag", "pass": True, "score": 2, + "evidence": f"版本/release tag: {tag}"}) + else: + items.append({"name": "版本 tag", "pass": False, "score": 1, + "evidence": f"repo_info 无显式 tag 字段(默认分支: {info.get('default_branch', 'unknown')}),建议打 tag 固定可复现版本"}) + + # 5) 容器化(Dockerfile / docker-compose)—— 复现环境 + container_hits = _has_path(paths, ("Dockerfile", "docker-compose.yml", + "docker-compose.yaml", ".devcontainer")) + if container_hits: + items.append({"name": "容器化环境", "pass": True, "score": 2, + "evidence": f"存在容器配置: {', '.join(container_hits[:3])}"}) + else: + items.append({"name": "容器化环境", "pass": False, "score": 0, + "evidence": "未找到 Dockerfile/docker-compose,复现环境依赖手工描述"}) + + return items + + +def compliance_items(license_info: dict[str, Any], file_texts: dict[str, str], + tree: list) -> list[dict[str, Any]]: + """合规性检查。 + + 返回检查项列表: [{"name","pass","score","evidence"}],score 0-2。 + """ + paths = _tree_paths(tree) + items: list[dict[str, Any]] = [] + + # 1) LICENSE 声明 + lic = license_info.get("license", "None") + recognized = license_info.get("recognized", False) + if recognized and lic != "None": + items.append({"name": "LICENSE 文件", "pass": True, "score": 2, + "evidence": f"LICENSE 声明为 {lic}"}) + elif lic == "None" and not (file_texts.get("LICENSE") or file_texts.get("LICENSE.txt") + or file_texts.get("LICENSE.md")): + items.append({"name": "LICENSE 文件", "pass": False, "score": 0, + "evidence": "缺少 LICENSE 文件"}) + else: + items.append({"name": "LICENSE 文件", "pass": False, "score": 1, + "evidence": "LICENSE 文件存在但类型未识别"}) + + # 2) SECURITY.md + sec_hits = _has_path(paths, ("SECURITY.md", "SECURITY", "security.md")) + if sec_hits: + items.append({"name": "安全策略 SECURITY.md", "pass": True, "score": 2, + "evidence": f"存在 {sec_hits[0]}"}) + else: + items.append({"name": "安全策略 SECURITY.md", "pass": False, "score": 0, + "evidence": "缺少 SECURITY.md,无安全披露流程"}) + + # 3) 版权头(采样 README/LICENSE 头部判断有无 Copyright) + sample = (file_texts.get("LICENSE", "") + "\n" + file_texts.get("README.md", "") + + "\n" + file_texts.get("README", "")) + has_copyright = ("copyright" in sample.lower()) or ("版权" in sample) or ("©" in sample) + if has_copyright: + items.append({"name": "版权声明", "pass": True, "score": 2, + "evidence": "LICENSE/README 中含 copyright/版权 声明"}) + else: + items.append({"name": "版权声明", "pass": False, "score": 1, + "evidence": "未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释)"}) + + # 4) 依赖合规(存在依赖清单即视为已声明,识别许可证更佳) + dep_present = bool(_has_path(paths, ("go.mod", "requirements.txt", "package.json", + "Cargo.toml", "pom.xml", "setup.py", "pyproject.toml"))) + if dep_present: + items.append({"name": "依赖清单声明", "pass": True, "score": 2, + "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)"}) + else: + items.append({"name": "依赖清单声明", "pass": False, "score": 1, + "evidence": "未发现标准依赖管理文件"}) + + # 5) CONTRIBUTING.md(社区合规) + contrib_hits = _has_path(paths, ("CONTRIBUTING.md", "CONTRIBUTING", "contributing.md")) + if contrib_hits: + items.append({"name": "贡献指南", "pass": True, "score": 2, + "evidence": f"存在 {contrib_hits[0]}"}) + else: + items.append({"name": "贡献指南", "pass": False, "score": 1, + "evidence": "缺少 CONTRIBUTING.md"}) + + return items + + +def data_privacy(tree: list, gitignore_text: str) -> dict[str, Any]: + """数据隐私检查。 + + 返回: { + "items": [{"name","pass","score","evidence"}], + "risks": [...], # 高风险项明细 + } + """ + paths = _tree_paths(tree) + items: list[dict[str, Any]] = [] + risks: list[str] = [] + + # 1) data/ 目录是否入库 + data_hits = _has_path(paths, ("data/", "dataset/", "datasets/")) + if data_hits: + items.append({"name": "数据目录入库", "pass": False, "score": 0, + "evidence": f"data/ 目录已入库: {', '.join(data_hits[:3])}(建议大文件走外部存储/DVC)"}) + risks.append(f"数据目录入库: {', '.join(data_hits[:3])}(可能含敏感数据)") + else: + items.append({"name": "数据目录入库", "pass": True, "score": 2, + "evidence": "未发现 data/ 目录入库"}) + + # 2) .env 是否入库 + env_hits = _has_path(paths, (".env", ".env.local", ".env.production")) + if env_hits: + items.append({"name": ".env 入库", "pass": False, "score": 0, + "evidence": f".env 已入库: {', '.join(env_hits[:3])}(高风险,疑似凭据泄露)"}) + risks.append(f".env 已入库: {', '.join(env_hits[:3])}(凭据泄露风险)") + else: + items.append({"name": ".env 入库", "pass": True, "score": 2, + "evidence": ".env 未入库"}) + + # 3) .gitignore 是否忽略 .env + gi = (gitignore_text or "").lower() + ignores_env = ".env" in gi + if ignores_env: + items.append({"name": ".gitignore 忽略 .env", "pass": True, "score": 2, + "evidence": ".gitignore 已配置忽略 .env"}) + else: + items.append({"name": ".gitignore 忽略 .env", "pass": False, "score": 1, + "evidence": ".gitignore 未忽略 .env(建议添加 .env)"}) + if not env_hits: + risks.append(".gitignore 未忽略 .env(预防性建议)") + + return {"items": items, "risks": risks} + + +def _score_10(items: list[dict[str, Any]], cap: float = 10.0) -> float: + """把检查项的 0-2 分聚合为 0-10 分:sum(score)/sum(max=2) * 10。""" + total = sum(it.get("score", 0) for it in items) + max_total = sum(2 for _ in items) + if max_total == 0: + return 0.0 + return round(min(cap, total / max_total * cap), 1) + + +# --------------------------------------------------------------------------- +# 数据采集(调 gitlink-cli;算法不依赖本节) +# --------------------------------------------------------------------------- + +def collect_file_texts(owner: str, repo: str, ref: str = "master") -> dict[str, str]: + """批量取关键文件文本。缺失文件返回空串(不出现在 dict 中)。""" + out: dict[str, str] = {} + for path in KEY_FILES: + try: + txt = c.file_text(owner, repo, path, ref=ref) + except Exception: + txt = "" + if txt and txt.strip(): + out[path] = txt + return out + + +def collect_tree(owner: str, repo: str, ref: str = "master") -> list: + """取根 tree(含扫 data/、.env、config、workflows 等)。""" + try: + return c.tree(owner, repo, path="", ref=ref) + except Exception: + return [] + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +def run(owner: str, repo: str) -> dict[str, Any]: + info = c.repo_info(owner, repo) + ref = info.get("default_branch") or "master" + + file_texts = collect_file_texts(owner, repo, ref) + tree = collect_tree(owner, repo, ref) + + license_text = (file_texts.get("LICENSE") or file_texts.get("LICENSE.txt") + or file_texts.get("LICENSE.md") or "") + license_info = identify_license(license_text) + + repro = repro_checks(file_texts, tree, repo_info=info) + compliance = compliance_items(license_info, file_texts, tree) + gitignore_text = file_texts.get(".gitignore", "") + dp = data_privacy(tree, gitignore_text) + + # 汇总密钥扫描(扫所有已取文本 + gitignore) + all_secrets: list[dict[str, Any]] = [] + for path, txt in file_texts.items(): + all_secrets.extend(scan_secrets(txt, file=path)) + + repro_score = _score_10(repro) + compliance_score = _score_10(compliance + dp["items"]) + + # 风险项汇总 + risks: list[dict[str, Any]] = [] + for it in repro + compliance + dp["items"]: + if not it["pass"]: + risks.append({"area": "repro/compliance", "name": it["name"], + "evidence": it["evidence"], "level": "medium"}) + for r in dp["risks"]: + risks.append({"area": "privacy", "name": "数据隐私", "evidence": r, "level": "high"}) + for s in all_secrets: + risks.append({"area": "secret", "name": s["category"], "file": s["file"], + "line": s["line"], "detail": s["detail"], + "level": s["level"]}) + # 按级别排序 + level_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3} + risks.sort(key=lambda r: level_rank.get(r.get("level", "low"), 9)) + + return { + "scenario": "S3_compliance_reproducibility", + "repo": f"{owner}/{repo}", + "default_branch": ref, + "license": license_info["license"], + "repro_items": repro, + "compliance_items": compliance, + "privacy_items": dp["items"], + "secrets": all_secrets, + "risks": risks, + "repro_score": repro_score, + "compliance_score": compliance_score, + "meta": {"key_files_found": sorted(file_texts.keys()), + "tree_size": len(tree), + "languages": c.languages(owner, repo)}, + } + + +# --------------------------------------------------------------------------- +# 渲染:Markdown 报告 +# --------------------------------------------------------------------------- + +def _grade(score: float) -> str: + if score >= 8: + return "良好" + if score >= 6: + return "及格" + if score >= 4: + return "偏弱" + return "较差" + + +def render_report(result: dict[str, Any]) -> str: + repo = result["repo"] + lic = result["license"] + rs = result["repro_score"] + cs = result["compliance_score"] + lines = [ + f"# 科研项目合规与复现性检查报告 — {repo}\n", + f"> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」\n", + f"- **默认分支**: `{result.get('default_branch', 'master')}`", + f"- **识别许可证**: `{lic}`", + f"- **复现性评分**: **{rs}/10**({_grade(rs)})", + f"- **合规性评分**: **{cs}/10**({_grade(cs)})\n", + ] + + # 检查清单表 + lines += ["## 一、复现性检查清单\n", + "| 检查项 | 通过 | 得分 | 证据 |", + "|--------|:----:|:----:|------|"] + for it in result["repro_items"]: + mark = "PASS" if it["pass"] else "FAIL" + lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |") + + lines += ["\n## 二、合规性检查清单\n", + "| 检查项 | 通过 | 得分 | 证据 |", + "|--------|:----:|:----:|------|"] + for it in result["compliance_items"]: + mark = "PASS" if it["pass"] else "FAIL" + lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |") + + lines += ["\n## 三、数据隐私检查\n", + "| 检查项 | 通过 | 得分 | 证据 |", + "|--------|:----:|:----:|------|"] + for it in result["privacy_items"]: + mark = "PASS" if it["pass"] else "FAIL" + lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |") + + # 风险项表 + risks = result["risks"] + lines += ["\n## 四、风险项(按严重程度排序)\n", + "| 级别 | 类别 | 名称 | 文件:行 | 证据 |", + "|:----:|------|------|---------|------|"] + if risks: + for r in risks: + lvl = r.get("level", "medium") + area = r.get("area", "") + name = r.get("name", "") + file_loc = "" + if r.get("file"): + file_loc = f"{r['file']}:{r.get('line', '')}" + ev = r.get("evidence") or r.get("detail", "") + lines.append(f"| {lvl} | {area} | {name} | {file_loc} | {ev} |") + else: + lines.append("| — | — | 无风险项 | — | 全部检查通过 |") + + # 密钥小结 + secrets = result.get("secrets", []) + if secrets: + lines.append(f"\n> 检出 **{len(secrets)}** 处疑似敏感信息(见风险项表),请人工复核确认。\n") + + lines.append(f"\n_复现分 {rs}/10 · 合规分 {cs}/10 · 树节点 {result.get('meta',{}).get('tree_size','?')}_\n") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S3 科研项目合规与复现性检查") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--out", help="输出目录(写 repro.json + compliance_report.md);省略则打印 JSON") + args = ap.parse_args() + + result = run(args.owner, args.repo) + + if args.out: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "repro.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "compliance_report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + print(f"✓ S3 合规/复现检查完成 → {args.out}/repro.json | compliance_report.md") + print(f" 许可证: {result['license']}") + print(f" 复现分: {result['repro_score']}/10 合规分: {result['compliance_score']}/10") + print(f" 风险项: {len(result['risks'])} 处") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/requirements.txt b/scripts/research/requirements.txt new file mode 100644 index 0000000..a134d93 --- /dev/null +++ b/scripts/research/requirements.txt @@ -0,0 +1,6 @@ +# 子赛题四·科研辅助算法层依赖 +# 数据采集/报告/匹配/进度/合规/洞悉 仅用 Python 标准库(subprocess/json/sqlite3/re/datetime),无需第三方库。 +# 实测真正被 import 的第三方库只有以下两个: +networkx>=3.1 # S2 科研知识图谱构建 +plotly>=5.18 # S6 科研成果可视化(交互 HTML) +# (如后续 S4 升级为 TF-IDF、S6 改用模板渲染,再按需加 scikit-learn / jinja2) diff --git a/scripts/research/research.py b/scripts/research/research.py new file mode 100644 index 0000000..a58da1b --- /dev/null +++ b/scripts/research/research.py @@ -0,0 +1,198 @@ +"""research.py — 科研情报全链路编排器(打通五主线)。 + +一条命令跑通:选分类(explore) → ③ 热点追踪 → ② 主体画像 → ④ 创新启发 → ⑤ 合规校验 → ① 项目分析。 +in-process 复用各 pillar 的函数(非 subprocess),共享数据;产物按 pillar 分目录 + 顶层 chain.json/chain_report.md。 + +用法: + python research.py --category 深度学习 --repo owner/repo --out ./out/chain [--limit 20] + python research.py --category 深度学习 # 焦点仓自动取热点榜 top-1 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: F401 (各 pillar 依赖共享) +import hotspot as H +import profile as P +import inspire as I +import repro as R +import lineage as L + + +def _write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +def _save(out_root: str, pillar: str, result: dict, report_md: str | None = None) -> None: + if not out_root: + return + d = os.path.join(out_root, pillar) + os.makedirs(d, exist_ok=True) + _write(os.path.join(d, f"{pillar}.json"), json.dumps(result, ensure_ascii=False, indent=2)) + if report_md: + _write(os.path.join(d, "report.md"), report_md) + + +def _focal_from_trending(trending: list[dict]) -> tuple[str, str]: + for r in trending: + full = r.get("repo") or "" + if "/" in full: + o, rr = full.split("/", 1) + if o and rr and o.lower() != "default": + return o, rr + # 回退:允许 default/ + for r in trending: + full = r.get("repo") or "" + if "/" in full: + o, rr = full.split("/", 1) + if o and rr: + return o, rr + return "", "" + + +def run_chain(category: str, repo: str | None = None, limit: int = 20, out: str = "") -> dict: + summary: dict = {"scenario": "chain", "category": category, "pillars": {}} + + # ① 热点追踪 + sys.stderr.write(f"[chain] ① 热点追踪:分类 {category}(上限 {limit})…\n"); sys.stderr.flush() + raw = H.collect(category=category, limit=limit) + hot = H.compute(raw) + trending = hot.get("trending_repos") or [] + _save(out, "hotspot", hot, getattr(H, "_report_md", lambda x: "")(hot)) + + # ② 焦点仓 + if repo and "/" in repo: + owner, repo_name = repo.split("/", 1) + else: + owner, repo_name = _focal_from_trending(trending) + summary["focal_repo"] = f"{owner}/{repo_name}" if owner else "(未解析到)" + sys.stderr.write(f"[chain] 焦点仓:{owner}/{repo_name}\n"); sys.stderr.flush() + summary["hotspot"] = { + "repo_count": (hot.get("meta") or {}).get("repo_count"), + "trending_top": trending[0]["repo"] if trending else "", + "topic_heat": [t["topic"] for t in (hot.get("topic_heat") or [])[:5]], + } + + if not (owner and repo_name): + sys.stderr.write("[chain] ⚠ 未解析到焦点仓,后续画像/启发/合规/分析跳过\n") + _finalize(out, summary, hot) + return summary + + ctx = {"category": category, + "topic_heat": [t["topic"] for t in (hot.get("topic_heat") or [])[:6]]} + + # ③ 主体画像 + sys.stderr.write("[chain] ② 主体画像…\n"); sys.stderr.flush() + proj = P.project_profile(owner, repo_name) + top_login = ((hot.get("core_scholars") or [{}])[0]).get("login") or "" + scholar = P.scholar_profile(top_login) if top_login else None + prof_result = {"scenario": "profile", "mode": "chain", + "project": proj, "scholar": scholar} + prof_md = [] + prof_md.append(P.render_report({"mode": "project", "profiles": [proj]})) + if scholar: + prof_md.append(P.render_report({"mode": "scholar", "profiles": [scholar]})) + _save(out, "profile", prof_result, "\n".join(prof_md)) + summary["pillars"]["profile"] = { + "repo": proj.get("repo"), "score_total": proj.get("score_total"), + "scholar": top_login or None, + } + + # ④ 创新启发 + sys.stderr.write("[chain] ③ 创新启发…\n"); sys.stderr.flush() + ins = I.analyze_repo(owner, repo_name, context=ctx) + _save(out, "inspire", ins, I.render_report(ins)) + summary["pillars"]["inspire"] = { + "gap_topics": (ins.get("gap_topics") or [])[:5], + "candidates": len(ins.get("candidates") or []), + "innovations": len(ins.get("innovation_points") or []), + "llm_used": ins.get("llm_used"), + } + + # ⑤ 合规校验 + sys.stderr.write("[chain] ④ 合规校验…\n"); sys.stderr.flush() + try: + rep = R.run(owner, repo_name) + _save(out, "repro", rep, R.render_report(rep)) + summary["pillars"]["repro"] = { + "license": rep.get("license"), + "repro_score": rep.get("repro_score"), + "compliance_score": rep.get("compliance_score"), + } + except Exception as e: + sys.stderr.write(f"[chain] repro 失败: {e!r}\n") + + # ⑥ 项目分析 + sys.stderr.write("[chain] ⑤ 项目分析…\n"); sys.stderr.flush() + try: + lin = L.lineage(owner, repo_name) + _save(out, "lineage", lin, L.render_report(lin)) + summary["pillars"]["lineage"] = { + "innovations": len(lin.get("innovation_points") or []), + } + except Exception as e: + sys.stderr.write(f"[chain] lineage 失败: {e!r}\n") + + _finalize(out, summary, hot) + return summary + + +def _finalize(out: str, summary: dict, hot: dict) -> None: + if not out: + return + _write(os.path.join(out, "chain.json"), json.dumps(summary, ensure_ascii=False, indent=2)) + _write(os.path.join(out, "chain_report.md"), _chain_report(summary)) + sys.stderr.write(f"[chain] ✓ 全链路完成,产物落 {out}\n"); sys.stderr.flush() + + +def _chain_report(s: dict) -> str: + lines = [ + "# 🔬 科研情报全链路报告", + "", + f"> 领域分类:**{s.get('category')}** · 焦点仓:`{s.get('focal_repo')}`", + "", + "## 链路", + "选分类(explore) → ① 热点追踪 → ② 主体画像 → ③ 创新启发 → ④ 合规校验 → ⑤ 项目分析", + "", + ] + h = s.get("hotspot") or {} + lines.append("### ① 热点追踪") + lines.append(f"- 榜首:`{h.get('trending_top')}`;领域主题:" + + "、".join(f"`{t}`" for t in h.get("topic_heat", []))) + p = s.get("pillars") or {} + if p.get("profile"): + lines.append("\n### ② 主体画像") + pr = p["profile"] + lines.append(f"- `{pr.get('repo')}` 研究维度评分 **{pr.get('score_total')}/40**;核心学者 `{pr.get('scholar')}`") + if p.get("inspire"): + lines.append("\n### ③ 创新启发") + ii = p["inspire"] + lines.append(f"- 缺口 {len(ii.get('gap_topics', []))} 主题、可合作候选 {ii.get('candidates')} 位、创新点 {ii.get('innovations')} 个;LLM 建议 {'✅' if ii.get('llm_used') else '⏭ 未启用'}") + if p.get("repro"): + lines.append("\n### ④ 合规校验") + rr = p["repro"] + lines.append(f"- 许可证 `{rr.get('license')}`;复现性 {rr.get('repro_score')}/10 · 合规 {rr.get('compliance_score')}/10") + if p.get("lineage"): + lines.append("\n### ⑤ 项目分析") + lines.append(f"- 识别 {p['lineage'].get('innovations')} 个创新点(详见 lineage/report.md)") + lines += ["", "各 pillar 完整产物见子目录 `{pillar}/`。", "", "---", "*由 gitlink-research chain 生成*"] + return "\n".join(lines) + + +def main() -> None: + ap = argparse.ArgumentParser(description="科研情报全链路编排器") + ap.add_argument("--category", "-c", required=True, help="领域分类(中文名或 id,如 深度学习/32)") + ap.add_argument("--repo", default="", help="焦点仓 owner/repo(省略则取热点榜 top-1)") + ap.add_argument("--limit", type=int, default=20, help="热点榜上限(默认 20)") + ap.add_argument("--out", "-o", default="", help="输出目录") + args = ap.parse_args() + run_chain(args.category, repo=args.repo or None, limit=args.limit, out=args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/test_graph_build.py b/scripts/research/test_graph_build.py new file mode 100644 index 0000000..4cb7b94 --- /dev/null +++ b/scripts/research/test_graph_build.py @@ -0,0 +1,272 @@ +"""test_graph_build.py — S2 知识图谱的纯单元测试(不联网、不调 gitlink-cli)。 + +把「取数」与「算法」分离:build_graph() 只吃已构造好的 mock 数据结构。 +运行:`python test_graph_build.py` 或 `pytest scripts/research/` +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import graph_build as G # noqa: E402 + + +# --------------------------------------------------------------------------- +# mock 数据构造 +# --------------------------------------------------------------------------- + +def _mock_repos(): + return [ + { + "fullname": "alice/mindspore-vision", + "identifier": "mindspore-vision", + "author": {"login": "alice"}, + "description": "deep learning library for computer vision, " + "image classification and object detection using CNN", + "language": {"name": "Python"}, + "praises_count": 120, + "forked_count": 30, + }, + { + "fullname": "bob/mindnlp", + "identifier": "mindnlp", + "author": {"login": "bob"}, + "description": "A nlp library: transformer, bert, text classification", + "language": {"name": "Python"}, + "praises_count": 200, + "forked_count": 50, + }, + ] + + +def _mock_contributors_map(): + # alice 既贡献自己的 repo,也贡献 bob 的 repo(→ 核心学者 + collaborates_with) + return { + "alice/mindspore-vision": [ + {"login": "alice", "contribution_perc": "60.0%"}, + {"login": "carol", "contribution_perc": "40.0%"}, + {"login": "i-robot", "contribution_perc": "0.0%"}, # bot,应被过滤 + ], + "bob/mindnlp": [ + {"login": "alice", "contribution_perc": "10.0%"}, + {"login": "bob", "contribution_perc": "90.0%"}, + {"login": "dependabot", "contribution_perc": "1.0%"}, # bot + ], + } + + +def _mock_readmes(): + return { + "alice/mindspore-vision": "pytorch and resnet for object detection", + "bob/mindnlp": "pretrain gpt and llm models", + } + + +def _build(): + return G.build_graph(_mock_repos(), _mock_contributors_map(), + {}, _mock_readmes(), keywords=["vision", "nlp"]) + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- + +def test_is_bot(): + assert G.is_bot("i-robot") is True + assert G.is_bot("dependabot[bot]") is True + assert G.is_bot("alice") is False + assert G.is_bot("") is True + + +def test_parse_ratio(): + assert G.parse_ratio("60.0%") == 0.6 + assert abs(G.parse_ratio("0.5") - 0.5) < 1e-9 + assert G.parse_ratio(0.3) == 0.3 + assert G.parse_ratio(None) == 0.0 + assert G.parse_ratio("n/a") == 0.0 + + +# --------------------------------------------------------------------------- +# 建图:节点计数 +# --------------------------------------------------------------------------- + +def test_node_counts(): + result = _build() + by_type = {} + for n in result["nodes"]: + by_type[n["type"]] = by_type.get(n["type"], 0) + 1 + assert by_type.get("repo", 0) == 2 + # alice / carol / bob = 3 个学者(bot 被过滤) + assert by_type.get("scholar", 0) == 3 + # 主题:CV + deep_learning + nlp = 至少 3 个 + assert by_type.get("topic", 0) >= 3 + assert result["meta"]["repo_count"] == 2 + assert result["meta"]["scholar_count"] == 3 + + +def test_repo_node_props(): + result = _build() + repo_nodes = [n for n in result["nodes"] if n["type"] == "repo"] + labels = {n["label"] for n in repo_nodes} + assert "alice/mindspore-vision" in labels + r = [n for n in repo_nodes if n["label"] == "alice/mindspore-vision"][0] + assert r["props"]["language"] == "Python" + assert r["props"]["stars"] == 120 + assert r["props"]["forks"] == 30 + + +# --------------------------------------------------------------------------- +# 建图:边 +# --------------------------------------------------------------------------- + +def test_contributes_to_weight(): + result = _build() + edges = result["edges"] + contrib = [e for e in edges if e["type"] == "contributes_to" + and e["source"] == "scholar:alice" + and e["target"] == "repo:alice/mindspore-vision"] + assert contrib, "应有 alice→自己repo 的 contributes_to 边" + # 60% → 0.6 + assert abs(contrib[0]["weight"] - 0.6) < 1e-9 + + +def test_owns_edge(): + result = _build() + owns = [e for e in result["edges"] if e["type"] == "owns" + and e["source"] == "scholar:bob" + and e["target"] == "repo:bob/mindnlp"] + assert owns, "bob 应有 owns 边到自己的 repo" + + +def test_covers_topic_weight_range(): + """covers_topic 权重应在 (0, 1] 且 <= 1。""" + result = _build() + covers = [e for e in result["edges"] if e["type"] == "covers_topic"] + assert covers, "应至少有一条 covers_topic 边" + for e in covers: + assert 0.0 <= e["weight"] <= 1.0 + # 命中 computer_vision 主题(description 含 object detection/image classification) + cv_edges = [e for e in covers if e["target"] == "topic:computer_vision"] + assert cv_edges, "应识别出 computer_vision 主题" + + +def test_collaborates_with(): + """alice 与 carol 同在 alice/mindspore-vision → 至少一条 collaborates_with。""" + result = _build() + collab = [e for e in result["edges"] if e["type"] == "collaborates_with"] + assert collab, "应有 collaborates_with 边" + pair = {e["source"] for e in collab} + assert "scholar:alice" in pair + + +def test_related_to_when_topic_cooccur(): + """computer_vision 与 deep_learning 在同一 repo 共现 → related_to。""" + result = _build() + related = [e for e in result["edges"] if e["type"] == "related_to" + and e["source"] == "topic:computer_vision"] + # mindspore-vision 的 description 同时命中 CV + deep_learning + assert related, "共现主题应有 related_to 边" + assert any(e["target"] == "topic:deep_learning" for e in related) + + +# --------------------------------------------------------------------------- +# 衍生统计 +# --------------------------------------------------------------------------- + +def test_core_scholars(): + result = _build() + # alice 出现在 2 个 repo → 排首位 + top = result["core_scholars"][0] + assert top["login"] == "alice" + assert top["repo_count"] == 2 + + +def test_topic_heat_top(): + result = _build() + heat = result["topic_heat"] + assert heat, "应有主题热度榜" + # deep_learning 在两个 repo 都命中 → 应在前列 + topics = [h["topic"] for h in heat] + assert "deep_learning" in topics + + +# --------------------------------------------------------------------------- +# 渲染 +# --------------------------------------------------------------------------- + +def test_render_mermaid_header(): + result = _build() + mmd = G.render_mermaid(result) + assert "graph TD" in mmd + assert "classDef" in mmd # 着色定义 + assert "```" in mmd + + +def test_render_mermaid_node_limit(): + """节点过多时应被截断到 node_limit。""" + big_repos = [] + big_contribs = {} + big_readmes = {} + for i in range(60): + fn = f"u{i}/repo{i}" + big_repos.append({ + "fullname": fn, "identifier": f"repo{i}", + "author": {"login": f"u{i}"}, "description": "deep learning", + "language": {"name": "Python"}, "praises_count": 0, "forked_count": 0, + }) + big_contribs[fn] = [{"login": f"u{i}", "contribution_perc": "100%"}] + big_readmes[fn] = "deep learning" + result = G.build_graph(big_repos, big_contribs, {}, big_readmes, + keywords=["dl"]) + mmd = G.render_mermaid(result, node_limit=40) + # mermaid 里出现的节点声明数应 <= 40 + node_lines = [ln for ln in mmd.splitlines() if '["' in ln and "-->" not in ln] + assert len(node_lines) <= 40 + + +def test_render_dot_header(): + result = _build() + dot = G.render_dot(result) + assert dot.startswith("digraph G") + assert "fillcolor" in dot + + +def test_render_report_sections(): + result = _build() + md = G.render_report(result) + assert "知识图谱报告" in md + assert "主题热度榜" in md + assert "核心学者" in md + assert "deep_learning" in md or "computer_vision" in md + + +def test_render_report_empty_safe(): + result = {"scenario": "S2", "keywords": [], "nodes": [], "edges": [], + "core_scholars": [], "core_teams": [], "topic_heat": [], + "meta": {"repo_count": 0, "node_count": 0, "edge_count": 0, + "scholar_count": 0, "topic_count": 0}} + md = G.render_report(result) + assert "知识图谱报告" in md + assert "未识别" in md or "暂无" in md + + +def test_empty_inputs(): + """空输入不应抛异常。""" + result = G.build_graph([], {}, {}, {}) + assert result["meta"]["node_count"] == 0 + assert result["meta"]["edge_count"] == 0 + assert result["nodes"] == [] + + +# --------------------------------------------------------------------------- + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} graph_build tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/scripts/research/test_helpers.py b/scripts/research/test_helpers.py new file mode 100644 index 0000000..46245fa --- /dev/null +++ b/scripts/research/test_helpers.py @@ -0,0 +1,80 @@ +"""test_helpers.py — gitlink_data / collect 归一化工具的纯单元测试(不联网)。 + +运行:`pytest scripts/research/` 或 `python scripts/research/test_helpers.py` +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import gitlink_data as gd # noqa: E402 +import collect # noqa: E402 + + +def test_first_list_list_input(): + assert gd.first_list([1, 2, 3]) == [1, 2, 3] + + +def test_first_list_known_key(): + assert gd.first_list({"projects": [{"id": 1}], "total_count": 1}) == [{"id": 1}] + + +def test_first_list_single_list_value(): + # 无已知键但只有一个列表值时,兜底返回它 + assert gd.first_list({"whatever": [9, 9]}) == [9, 9] + + +def test_first_list_empty(): + assert gd.first_list({"total_count": 0}) == [] + assert gd.first_list(None) == [] + + +def test_total_count(): + assert gd.total_count({"total_count": 42}) == 42 + assert gd.total_count({"totalCount": 7}) == 7 + assert gd.total_count({"projects": []}) is None + + +def test_login_of_flat(): + assert collect.login_of({"login": "whale"}) == "whale" + + +def test_login_of_nested_author(): + assert collect.login_of({"author": {"login": "baoerjun"}}) == "baoerjun" + + +def test_login_of_name_fallback(): + assert collect.login_of({"name": "surponess"}) == "surponess" + + +def test_login_of_empty(): + assert collect.login_of({}) == "" + assert collect.login_of("not a dict") == "" + + +def test_repo_fullname_with_author(): + project = {"identifier": "gitlink-cli", "author": {"login": "whale_hihihi"}} + assert collect.repo_fullname(project) == "whale_hihihi/gitlink-cli" + + +def test_repo_fullname_missing_owner(): + assert collect.repo_fullname({"identifier": "foo"}) == "foo" + + +def test_as_int_as_float(): + assert collect.as_int("12") == 12 + assert collect.as_int(None) == 0 + assert collect.as_float("1.5") == 1.5 + assert collect.as_float("x", -1.0) == -1.0 + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} helper tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/scripts/research/test_lineage.py b/scripts/research/test_lineage.py new file mode 100644 index 0000000..d46c092 --- /dev/null +++ b/scripts/research/test_lineage.py @@ -0,0 +1,260 @@ +"""test_lineage.py — lineage.py 纯函数单元测试。 + +不联网、不调 gitlink-cli。把「取数」与「算法」分离:算法函数接收已取好的 +Python 数据结构,本测试用 mock 数据喂算法。 + +运行: python test_lineage.py +""" +from __future__ import annotations + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# 仅 import 算法纯函数,绝不触发联网取数 +import lineage as L # noqa: E402 + + +class TestExperimentClassifier(unittest.TestCase): + def test_is_experiment_file_hits(self): + cases = [ + "experiments/mnist/run.py", + "experiment_train.py", + "benchmark/imagenet/eval.py", + "eval/metrics.py", + "tests/test_model.py", + "test/foo.py", + "data/dataset.csv", + "src/benchmark_infer.py", + ] + for p in cases: + with self.subTest(p=p): + self.assertTrue(L.is_experiment_file(p), f"应判为实验文件: {p}") + + def test_is_experiment_file_misses(self): + cases = [ + "src/model.py", + "README.md", + "docs/index.md", + "main.go", + "config.yaml", + "pkg/utils/util.py", + ] + for p in cases: + with self.subTest(p=p): + self.assertFalse(L.is_experiment_file(p), f"不应判为实验文件: {p}") + + def test_empty(self): + self.assertFalse(L.is_experiment_file("")) + self.assertFalse(L.is_experiment_file(None)) # type: ignore[arg-type] + + +class TestDocClassifier(unittest.TestCase): + def test_md_anywhere(self): + self.assertTrue(L.is_doc_file("README.md")) + self.assertTrue(L.is_doc_file("docs/guide.md")) + self.assertTrue(L.is_doc_file("deep/nested/notes.md")) + self.assertTrue(L.is_doc_file("GUIDE.MD")) # 大小写不敏感 + + def test_docs_dir(self): + self.assertTrue(L.is_doc_file("docs/index.html")) + self.assertTrue(L.is_doc_file("docs/config.yaml")) + self.assertTrue(L.is_doc_file("a/docs/b.txt")) + + def test_non_doc(self): + self.assertFalse(L.is_doc_file("src/main.py")) + self.assertFalse(L.is_doc_file("tests/x.py")) + self.assertFalse(L.is_doc_file("data.csv")) + self.assertFalse(L.is_doc_file("")) + + +class TestTimestampParse(unittest.TestCase): + def test_iso_string(self): + t = L._to_epoch("2024-05-01T08:00:00Z") + self.assertGreater(t, 0) + # 反解回日期 + self.assertTrue(L._iso_date(t).startswith("2024-05-01")) + + def test_int_seconds(self): + self.assertAlmostEqual(L._to_epoch(1714521600), 1714521600.0) + + def test_int_millis(self): + self.assertAlmostEqual(L._to_epoch(1714521600000), 1714521600.0) + + def test_garbage(self): + self.assertEqual(L._to_epoch(None), 0.0) + self.assertEqual(L._to_epoch(""), 0.0) + self.assertEqual(L._to_epoch("not-a-date"), 0.0) + + def test_iso_date_zero(self): + self.assertEqual(L._iso_date(0.0), "") + + +class TestBranchMap(unittest.TestCase): + def test_single_default_branch(self): + commits = [ + {"timestamp": "2024-01-01T00:00:00Z"}, + {"timestamp": "2024-06-01T00:00:00Z"}, + {"timestamp": "2024-03-01T00:00:00Z"}, + ] + bm = L.build_branch_map(commits, "master") + self.assertEqual(len(bm), 1) + b = bm[0] + self.assertEqual(b["name"], "master") + self.assertTrue(b["is_default"]) + self.assertEqual(b["commits"], 3) + # 最后活跃应取最大值 2024-06-01 + self.assertTrue(b["last_active"].startswith("2024-06-01")) + + def test_empty(self): + bm = L.build_branch_map([], "main") + self.assertEqual(bm, [{"name": "main", "commits": 0, + "last_active": "", "is_default": True}]) + + def test_non_list(self): + bm = L.build_branch_map(None, "main") # type: ignore[arg-type] + self.assertEqual(bm[0]["commits"], 0) + + +class TestCommitTimeline(unittest.TestCase): + def test_aggregation_and_sort(self): + commits = [ + {"timestamp": "2024-01-02T00:00:00Z"}, + {"timestamp": "2024-01-02T12:00:00Z"}, + {"timestamp": "2024-01-01T00:00:00Z"}, + ] + tl = L.commit_timeline(commits, bucket="day") + self.assertEqual(tl, [ + {"date": "2024-01-01", "count": 1}, + {"date": "2024-01-02", "count": 2}, + ]) + + def test_skips_garbage(self): + commits = [{"timestamp": "bad"}, {"timestamp": ""}] + self.assertEqual(L.commit_timeline(commits), []) + + +class TestPrMergePatterns(unittest.TestCase): + def test_extract_and_sort(self): + prs = [ + {"index": 10, "title": "feat A", "status": 1, + "pr_created_unix": 1717200000, "changed_files": 5}, + {"index": 2, "title": "feat B", "status": 1, + "pr_merged_unix": 1714521600, "changed_files": 20}, + {"index": 5, "title": "feat C", "status": 1, + "pr_created_unix": 1715000000, "additions": 3, "deletions": 4}, + ] + out = L.pr_merge_patterns(prs) + self.assertEqual(len(out), 3) + # 升序:1714521600(2024-05-01) < 1715000000 < 1717200000 + self.assertEqual(out[0]["number"], 2) + self.assertEqual(out[0]["changed_files"], 20) + self.assertTrue(out[0]["merged_time"].startswith("2024-05")) + # additions/deletions 兜底近似 + last = next(p for p in out if p["number"] == 5) + self.assertEqual(last["changed_files"], 7) + # 空 merged_time 排末尾 + no_time = L.pr_merge_patterns([ + {"index": 1, "title": "x", "status": 1}, + {"index": 2, "title": "y", "status": 1, "pr_created_unix": 1714521600}, + ]) + self.assertEqual(no_time[-1]["number"], 1) + + def test_empty(self): + self.assertEqual(L.pr_merge_patterns([]), []) + + +class TestDocEvolution(unittest.TestCase): + def test_filter_docs(self): + tree = [ + {"name": "guide.md", "path": "docs/guide.md", "date": "2024-03-01"}, + {"name": "index.html", "path": "docs/index.html"}, + {"name": "model.py", "path": "src/model.py"}, # 排除 + {"name": "old_2022-01-01.md", "path": "docs/old_2022-01-01.md"}, + ] + out = L.doc_evolution(tree) + files = {d["file"] for d in out} + self.assertIn("guide.md", files) + self.assertIn("index.html", files) + self.assertIn("old_2022-01-01.md", files) + self.assertNotIn("model.py", files) + # 文件名日期回退 + old = next(d for d in out if d["file"] == "old_2022-01-01.md") + self.assertEqual(old["last_date"], "2022-01-01") + # 显式 date 字段优先 + g = next(d for d in out if d["file"] == "guide.md") + self.assertEqual(g["last_date"], "2024-03-01") + + +class TestInnovationPoints(unittest.TestCase): + def test_high_impact_and_milestone(self): + prs = [ + {"index": 1, "title": "chore: typo", "status": 1, + "pr_created_unix": 1714521600, "changed_files": 2}, # 低影响,无关键词 + {"index": 2, "title": "feat: add transformer model", "status": 1, + "pr_created_unix": 1715000000, "changed_files": 25}, # 大规模+关键词 + {"index": 3, "title": "implement benchmark suite", "status": 1, + "pr_created_unix": 1717200000, "changed_files": 4}, # 仅关键词 + ] + out = L.innovation_points(prs, commits=[]) + # 应识别出 PR#2 和 PR#3,PR#1 被过滤 + nums = sorted(it["description"] for it in out) + self.assertTrue(any("transformer" in n.lower() for n in nums)) + self.assertTrue(any("benchmark" in n.lower() for n in nums)) + cats = [it["category"] for it in out] + self.assertIn("大规模重构/新特性", cats) + self.assertIn("特性引入", cats) + # 大规模 PR 排前(impact 更高) + self.assertIn("transformer", out[0]["description"].lower()) + # 每条都带证据 + for it in out: + self.assertTrue(it["evidence"]) + self.assertTrue(it["category"]) + + def test_empty(self): + self.assertEqual(L.innovation_points([], []), []) + + def test_top_limit(self): + prs = [{"index": i, "title": f"add feature {i}", "status": 1, + "pr_created_unix": 1714521600 + i * 86400, "changed_files": 15} + for i in range(20)] + out = L.innovation_points(prs, commits=[], top=5) + self.assertEqual(len(out), 5) + + +class TestRender(unittest.TestCase): + """渲染函数不抛异常、产出非空。""" + + def _result(self): + return { + "scenario": "S1_repository_research_insight", + "repo": "o/r", "default_branch": "master", + "commit_timeline": [{"date": "2024-01-01", "count": 3}], + "branch_map": [{"name": "master", "commits": 5, "last_active": "2024-06-01", + "is_default": True}], + "pr_merge_patterns": [{"number": 2, "title": "feat A", "status": 1, + "merged_time": "2024-06-01", "changed_files": 9}], + "doc_evolution": [{"file": "guide.md", "last_date": "2024-03-01"}], + "experiment_files": ["benchmark/eval.py"], + "innovation_points": [{"description": "feat A", "evidence": "PR #2", + "category": "特性引入"}], + "meta": {"commit_count": 5, "merged_pr_count": 1, "doc_count": 1, + "experiment_file_count": 1}, + } + + def test_report(self): + r = L.render_report(self._result()) + self.assertIn("仓库级科研项目洞悉报告", r) + self.assertIn("master", r) + self.assertIn("feat A", r) + + def test_mermaid(self): + m = L.render_mermaid(self._result()) + self.assertIn("gitGraph", m) + self.assertIn("master", m) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/research/test_match.py b/scripts/research/test_match.py new file mode 100644 index 0000000..b7ff35c --- /dev/null +++ b/scripts/research/test_match.py @@ -0,0 +1,77 @@ +"""test_match.py — S4 协作匹配的纯单元测试(不联网)。 + +运行:`pytest scripts/research/` 或 `python scripts/research/test_match.py` +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import match as M # noqa: E402 + + +def test_cosine_basic(): + assert M.cosine({"a": 1, "b": 0}, {"a": 1, "b": 0}) == 1.0 + assert M.cosine({"a": 1}, {"b": 1}) == 0.0 + # 对称且在 [0,1] + v = M.cosine({"a": 1, "b": 2}, {"a": 2, "b": 1}) + assert 0.0 < v < 1.0 + + +def test_cosine_empty_safe(): + assert M.cosine({}, {"a": 1}) == 0.0 + assert M.cosine({}, {}) == 0.0 + + +def test_jaccard(): + assert M.jaccard(["python", "go"], ["python", "rust"]) == 1 / 3 + assert M.jaccard([], ["x"]) == 0.0 + + +def test_priority_weight(): + assert M._priority_weight({"priority_name": "高"}) == 3.0 + assert M._priority_weight({"priority_name": "urgent"}) == 3.0 + assert M._priority_weight({"priority_name": "普通"}) == 2.0 + assert M._priority_weight({"priority_name": "低"}) == 1.0 + assert M._priority_weight({}) == 1.0 + + +def test_parse_ratio_percent_string(): + assert M._parse_ratio("1.18%") == 0.0118 + assert abs(M._parse_ratio("50%") - 0.5) < 1e-9 + + +def test_parse_ratio_plain(): + assert M._parse_ratio(0.5) == 0.5 + assert M._parse_ratio("0.2") == 0.2 + assert M._parse_ratio(None) == 0.0 + assert M._parse_ratio("n/a") == 0.0 + + +def test_render_report_has_sections(): + result = { + "repo": "o/r", "gap_topics": ["deep_learning"], "needed_languages": ["python"], + "gap_signals": [{"type": "unresolved_issue", "topic": "deep_learning", + "evidence": "x", "priority": "高"}], + "candidates": [{"login": "alice", "score": 20.0, "topic_overlap": 0.5, + "language_match": 0.5, "activity_level": "high", + "repo_languages": ["python"], "reasons": ["覆盖缺口主题"]}], + "meta": {"pool_size": 1, "issue_sample": 1}, + } + md = M.render_report(result) + assert "缺口分析" in md or "技术缺口" in md + assert "alice" in md + mm = M.render_mermaid(result) + assert mm.startswith("```mermaid") and "alice" in mm + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} match tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/scripts/research/test_report.py b/scripts/research/test_report.py new file mode 100644 index 0000000..22d019d --- /dev/null +++ b/scripts/research/test_report.py @@ -0,0 +1,150 @@ +"""test_report.py — S5 进度跟踪与预警的纯单元测试(不联网)。 + +运行:`pytest scripts/research/` 或 `python scripts/research/test_report.py` +""" +import os +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import report as R # noqa: E402 + +UTC = timezone.utc +NOW = datetime(2026, 6, 29, 12, 0, 0, tzinfo=UTC) + + +def iso(dt): + return dt.isoformat() + + +def test_parse_time_iso(): + assert R.parse_time("2026-06-01T08:30:00+00:00").year == 2026 + assert R.parse_time("2026-06-01T08:30:00Z").month == 6 + + +def test_parse_time_unix(): + # 整数与整数字串都按秒级时间戳解析 + dt = R.parse_time(1717200000) + assert dt is not None and dt.year == 2024 + assert R.parse_time("1717200000").year == 2024 + + +def test_parse_time_none_and_garbage(): + assert R.parse_time(None) is None + assert R.parse_time("") is None + assert R.parse_time("not a date") is None + + +def test_in_window(): + lo = NOW - timedelta(days=7) + assert R.in_window(NOW - timedelta(days=2), 7, NOW) is True + assert R.in_window(NOW - timedelta(days=10), 7, NOW) is False + assert R.in_window(None, 7, NOW) is False + + +def test_week_stats_splits_this_and_last_week(): + commits = [ + {"timestamp": iso(NOW - timedelta(days=2)), "author": {"login": "a"}}, # 本周 + {"timestamp": iso(NOW - timedelta(days=3)), "author": {"login": "b"}}, # 本周 + {"timestamp": iso(NOW - timedelta(days=10)), "author": {"login": "a"}}, # 上周 + ] + issues = [ + {"created_at": iso(NOW - timedelta(days=1)), "status": "open"}, # 本周新增 + {"created_at": iso(NOW - timedelta(days=9)), "status": "open"}, # 上周新增 + ] + prs = [] + stats = R.week_stats(commits, issues, prs, ["a", "b"], NOW) + assert stats["this_week"]["commits"] == 2 + assert stats["last_week"]["commits"] == 1 + assert stats["this_week"]["issues_opened"] == 1 + assert stats["last_week"]["issues_opened"] == 1 + + +def test_week_stats_stale_issue(): + # 开放、最近活动 > 30 天 → stale + old = NOW - timedelta(days=40) + issues = [{"status": "open", "created_at": iso(old), "journals_updated_at": iso(old)}] + stats = R.week_stats([], issues, [], [], NOW) + assert stats["this_week"]["issues_stale"] == 1 + + +def test_trend(): + assert R.trend({"commits": 10}, {"commits": 5})["activity_level"] == "increasing" + assert R.trend({"commits": 2}, {"commits": 10})["activity_level"] == "decreasing" + assert R.trend({"commits": 10}, {"commits": 10})["activity_level"] == "stable" + # 上周为 0、本周有提交 → 100% + assert R.trend({"commits": 3}, {"commits": 0})["commit_delta_pct"] == 100.0 + + +def test_risk_low_activity(): + stats = {"this_week": {"commits": 1, "issues_stale": 0, "prs_open_stale": 0}} + warns = R.risk_warnings(stats, [], [], commits=None, now=NOW) + assert any(w["type"] == "low_activity" for w in warns) + + +def test_risk_bus_factor(): + # 一人占本周全部提交 → bus factor + commits = [{"timestamp": iso(NOW - timedelta(days=1)), "author": {"login": "only"}} for _ in range(5)] + stats = {"this_week": {"commits": 5, "issues_stale": 0, "prs_open_stale": 0}} + warns = R.risk_warnings(stats, [], [], commits=commits, now=NOW) + assert any(w["type"] == "bus_factor" for w in warns) + + +def test_milestone_progress_overdue(): + ms = [{"name": "v1.0", "status": "open", "effective_date": iso(NOW - timedelta(days=5))}] + issues = [ + {"milestone_name": "v1.0", "status": "closed"}, + {"milestone_name": "v1.0", "status": "open"}, + {"milestone_name": "v1.0", "status": "open"}, + ] + out = R.milestone_progress(ms, issues, NOW) + assert len(out) == 1 + assert out[0]["total"] == 3 and out[0]["closed"] == 1 + assert out[0]["completion_pct"] == round(100 / 3, 1) + assert out[0]["overdue"] is True + + +def test_render_report_contains_sections(): + # 直接构造一个最小 result 喂渲染器(不触网) + res = { + "repo": "o/r", "generated_at": iso(NOW), + "week_stats": {"this_week": {"commits": 1, "issues_opened": 0, "issues_closed": 0, + "issues_stale": 0, "prs_opened": 0, "prs_merged": 0, + "prs_open_stale": 0, "contributors_active": 1}, + "last_week": {"commits": 0, "issues_opened": 0, "issues_closed": 0, + "issues_stale": 0, "prs_opened": 0, "prs_merged": 0, + "prs_open_stale": 0, "contributors_active": 0}, + "window": {"this_week_start": iso(NOW), "now": iso(NOW), + "last_week_start": iso(NOW), "last_week_end": iso(NOW)}, + "total_contributors": 1}, + "trend": {"commit_delta_pct": 100.0, "activity_level": "increasing"}, + "milestones": [], "risk_warnings": [], + "meta": {"commits_fetched": 1, "issues_fetched": 0, "prs_fetched": 0, + "milestones_fetched": 0, "contributors_fetched": 1}, + } + md = R.render_report(res) + assert "周报" in md and "趋势" in md + + +def test_pr_status_string_and_int(): + # GitLink PR 列表 status 是字符串 'merged'/'open'/'closed' + assert R._pr_status({"status": "merged"}) == 1 + assert R._pr_status({"status": "open"}) == 0 + assert R._pr_status({"status": "closed"}) == 2 + # 详情/health 可能给 pull_request_status 整数 0/1/2 + assert R._pr_status({"pull_request_status": 1}) == 1 + assert R._pr_status({"pull_request_status": 0}) == 0 + assert R._pr_status({"pull_request_status": 2}) == 2 + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} report tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/scripts/research/test_repro.py b/scripts/research/test_repro.py new file mode 100644 index 0000000..2f1fa13 --- /dev/null +++ b/scripts/research/test_repro.py @@ -0,0 +1,278 @@ +"""test_repro.py — repro.py 的纯单元测试。 + +不联网、不调 gitlink-cli。把"取数"和"算法"分离:直接给算法函数喂 mock 数据。 + +运行: python test_repro.py +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import repro # noqa: E402 + +# 用一个轻量测试框架,避免依赖 unittest(保持无第三方依赖)。 +_failures: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + status = "PASS" if cond else "FAIL" + print(f" [{status}] {msg}") + if not cond: + _failures.append(msg) + + +def check_eq(actual, expected, msg: str) -> None: + ok = actual == expected + status = "PASS" if ok else "FAIL" + print(f" [{status}] {msg} (got={actual!r})") + if not ok: + _failures.append(f"{msg}: expected {expected!r}, got {actual!r}") + + +# --------------------------------------------------------------------------- +# 1) identify_license +# --------------------------------------------------------------------------- + +def test_identify_license(): + print("\n== test_identify_license ==") + cases = { + "Apache License\nVersion 2.0": "Apache-2.0", + "MIT License\n\nCopyright (c) 2024": "MIT", + "GNU GENERAL PUBLIC LICENSE\nVersion 3": "GPL", + "木兰宽松许可证, 第2版": "MulanPSL-2.0", + "MulanPSL v2": "MulanPSL-2.0", + "BSD 3-Clause License": "BSD", + "ISC License": "ISC", + "Mozilla Public License Version 2.0": "MPL", + "random text without any license keyword": "None", + "": "None", + " \n ": "None", + } + for text, expected in cases.items(): + info = repro.identify_license(text) + check_eq(info["license"], expected, f"identify_license({text[:24]!r})") + # 识别标志 + check(repro.identify_license("MIT License")["recognized"] is True, "MIT recognized=True") + check(repro.identify_license("nope")["recognized"] is False, "unknown recognized=False") + # GPL 优先于 LGPL:LGPL 文本应命中 LGPL(因为 LGPL 模式在 GPL 之前) + l = repro.identify_license("GNU Lesser General Public License v3") + check_eq(l["license"], "LGPL", "LGPL not misidentified as GPL") + + +# --------------------------------------------------------------------------- +# 2) scan_secrets +# --------------------------------------------------------------------------- + +def test_scan_secrets(): + print("\n== test_scan_secrets ==") + sample = """\ +API_KEY=sk_live_abcdef1234567890abcd +AWS_KEY=AKIAIOSFODNN7EXAMPLE +mail: someone@example.com +phone: 13812345678 +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA... +""" + findings = repro.scan_secrets(sample, file="config.env") + cats = {f["category"] for f in findings} + check("generic_api_key" in cats, "detect generic_api_key") + check("aws_access_key" in cats, "detect aws_access_key") + check("email" in cats, "detect email") + check("phone_cn" in cats, "detect phone_cn") + check("private_key" in cats, "detect private_key") + # 每条都有必要字段 + for f in findings: + check(set(["level", "category", "file", "line", "detail"]).issubset(f.keys()), + f"finding {f['category']} has required keys") + check_eq(f["file"], "config.env", f"{f['category']} file field") + # critical 级别存在 + levels = {f["level"] for f in findings} + check("critical" in levels, "critical level present (private key/aws)") + # 空文本 + check_eq(repro.scan_secrets(""), [], "empty text -> no findings") + # 无敏感信息文本 + check_eq(repro.scan_secrets("hello world\nnothing here"), [], "clean text -> no findings") + # 长串脱敏(detail 不超长) + long_token = "api_key=" + "a" * 60 + f2 = repro.scan_secrets(long_token, file="x") + if f2: + check(len(f2[0]["detail"]) <= 50, "long secret is masked in detail") + + +# --------------------------------------------------------------------------- +# 3) repro_checks +# --------------------------------------------------------------------------- + +def test_repro_checks(): + print("\n== test_repro_checks ==") + file_texts = { + "README.md": "# Project\n\n## Install\n pip install -e .\n## Build\ndocker build .\n" + "Dataset: download from xxx. reproduce: python run.py\nUsage: see docs.", + } + tree = [ + {"path": ".gitea/workflows/ci.yml"}, + {"path": "go.sum"}, + {"path": "requirements.txt"}, + {"path": "Dockerfile"}, + {"path": "src/main.go"}, + ] + items = repro.repro_checks(file_texts, tree, repo_info={"default_branch": "master"}) + names = {it["name"]: it for it in items} + check(names["CI 配置"]["pass"] is True, "CI detected") + check_eq(names["CI 配置"]["score"], 2, "CI score=2") + check(names["依赖锁文件"]["pass"] is True, "lockfile detected") + check(names["README 复现说明"]["pass"] is True, "README repro keywords detected") + check(names["README 复现说明"]["score"] >= 1, "README repro score>=1") + check(names["容器化环境"]["pass"] is True, "Dockerfile detected") + + # 无 CI / 无 lockfile 场景 + items2 = repro.repro_checks({}, [], repo_info={}) + names2 = {it["name"]: it for it in items2} + check(names2["CI 配置"]["pass"] is False, "no CI -> fail") + check_eq(names2["CI 配置"]["score"], 0, "no CI score=0") + check(names2["依赖锁文件"]["pass"] is False, "no lockfile -> fail") + check(names2["README 复现说明"]["pass"] is False, "no README -> fail") + # 版本 tag:无 tag 字段 → score=1(不扣满分但提示) + check_eq(names2["版本 tag"]["score"], 1, "unknown tag -> score=1") + # 有 tag 字段 → score=2 + items3 = repro.repro_checks({}, [], repo_info={"version": "v1.2.3"}) + names3 = {it["name"]: it for it in items3} + check_eq(names3["版本 tag"]["score"], 2, "version present -> score=2") + check(names3["版本 tag"]["pass"] is True, "version present -> pass") + + # score 范围合法 + for it in items: + check(0 <= it["score"] <= 2, f"{it['name']} score in [0,2]") + + +# --------------------------------------------------------------------------- +# 4) compliance_items +# --------------------------------------------------------------------------- + +def test_compliance_items(): + print("\n== test_compliance_items ==") + license_info = {"license": "MIT", "recognized": True, "evidence": "MIT License"} + file_texts = {"LICENSE": "MIT License\nCopyright (c) 2024 Test", "README.md": "see LICENSE"} + tree = [ + {"path": "SECURITY.md"}, + {"path": "CONTRIBUTING.md"}, + {"path": "requirements.txt"}, + ] + items = repro.compliance_items(license_info, file_texts, tree) + names = {it["name"]: it for it in items} + check(names["LICENSE 文件"]["pass"] is True, "LICENSE recognized") + check_eq(names["LICENSE 文件"]["score"], 2, "LICENSE score=2") + check(names["安全策略 SECURITY.md"]["pass"] is True, "SECURITY.md present") + check(names["版权声明"]["pass"] is True, "copyright present") + check(names["贡献指南"]["pass"] is True, "CONTRIBUTING present") + check(names["依赖清单声明"]["pass"] is True, "dep manifest present") + + # 缺失场景 + license_none = {"license": "None", "recognized": False, "evidence": "missing"} + items2 = repro.compliance_items(license_none, {}, []) + names2 = {it["name"]: it for it in items2} + check(names2["LICENSE 文件"]["pass"] is False, "no LICENSE -> fail") + check_eq(names2["LICENSE 文件"]["score"], 0, "no LICENSE score=0") + check(names2["安全策略 SECURITY.md"]["pass"] is False, "no SECURITY -> fail") + + +# --------------------------------------------------------------------------- +# 5) data_privacy +# --------------------------------------------------------------------------- + +def test_data_privacy(): + print("\n== test_data_privacy ==") + # 健康场景:无 data/,无 .env,.gitignore 忽略 .env + tree = [{"path": "src/main.py"}, {"path": ".gitignore"}] + dp = repro.data_privacy(tree, ".env\n*.key\nnode_modules/") + items = {it["name"]: it for it in dp["items"]} + check(items["数据目录入库"]["pass"] is True, "no data dir -> pass") + check(items[".env 入库"]["pass"] is True, "no .env -> pass") + check(items[".gitignore 忽略 .env"]["pass"] is True, "gitignore ignores .env") + check_eq(len(dp["risks"]), 0, "healthy repo -> 0 risks") + + # 风险场景:data/ 入库,.env 入库,gitignore 未忽略 .env + tree2 = [{"path": "data/raw.csv"}, {"path": ".env"}, {"path": "config/secrets.yml"}] + dp2 = repro.data_privacy(tree2, "node_modules/\n*.log") + items2 = {it["name"]: it for it in dp2["items"]} + check(items2["数据目录入库"]["pass"] is False, "data dir tracked -> fail") + check(items2[".env 入库"]["pass"] is False, ".env tracked -> fail") + check(items2[".gitignore 忽略 .env"]["pass"] is False, "gitignore missing .env") + check(len(dp2["risks"]) >= 1, "risky repo -> has risks") + + +# --------------------------------------------------------------------------- +# 6) 打分 _score_10 +# --------------------------------------------------------------------------- + +def test_score(): + print("\n== test_score ==") + # 5 项全 2 分 → 10 + full = [{"score": 2}] * 5 + check_eq(repro._score_10(full), 10.0, "all pass -> 10") + # 5 项全 0 → 0 + zero = [{"score": 0}] * 5 + check_eq(repro._score_10(zero), 0.0, "all fail -> 0") + # 混合:5 项中 3×2 + 2×0 = 6/10 = 6.0 + mixed = [{"score": 2}, {"score": 2}, {"score": 2}, {"score": 0}, {"score": 0}] + check_eq(repro._score_10(mixed), 6.0, "mixed 6/10 -> 6.0") + # 空列表 + check_eq(repro._score_10([]), 0.0, "empty -> 0") + # cap 不超 10 + over = [{"score": 2}] * 8 + check(repro._score_10(over) <= 10.0, "capped at 10") + + +# --------------------------------------------------------------------------- +# 7) render_report 端到端(用 mock 结果) +# --------------------------------------------------------------------------- + +def test_render_report(): + print("\n== test_render_report ==") + mock = { + "scenario": "S3_compliance_reproducibility", + "repo": "o/r", + "default_branch": "master", + "license": "MIT", + "repro_items": [{"name": "CI 配置", "pass": True, "score": 2, "evidence": "ci"}], + "compliance_items": [{"name": "LICENSE 文件", "pass": True, "score": 2, "evidence": "MIT"}], + "privacy_items": [{"name": ".env 入库", "pass": True, "score": 2, "evidence": "ok"}], + "secrets": [], + "risks": [], + "repro_score": 10.0, + "compliance_score": 10.0, + "meta": {"tree_size": 5}, + } + md = repro.render_report(mock) + check("# 科研项目合规与复现性检查报告" in md, "report has title") + check("MIT" in md, "report shows license") + check("10.0/10" in md, "report shows scores") + check("复现性检查清单" in md, "report has repro checklist") + check("风险项" in md, "report has risk section") + + +# --------------------------------------------------------------------------- + +def main(): + test_identify_license() + test_scan_secrets() + test_repro_checks() + test_compliance_items() + test_data_privacy() + test_score() + test_render_report() + + print() + if _failures: + print(f"RESULT: FAIL ({len(_failures)} failures)") + for m in _failures: + print(f" - {m}") + sys.exit(1) + else: + print("RESULT: ALL PASS") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/test_visual.py b/scripts/research/test_visual.py new file mode 100644 index 0000000..d2e36b4 --- /dev/null +++ b/scripts/research/test_visual.py @@ -0,0 +1,406 @@ +"""test_visual.py — S6 科研成果可视化的纯单元测试(不联网、不调 gitlink-cli)。 + +把「取数」与「算法」分离:算法函数接收已构造好的 Python 数据结构(mock commits/issues/...), +测试只覆盖 bin_weekly / contribution_heatmap / extract_paper_links / classify_artifacts, +不测 plotly 渲染。 + +运行:`python scripts/research/test_visual.py` +""" +import os +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import visual as V # noqa: E402 + + +# --------------------------------------------------------------------------- +# 辅助:构造「最近 N 天」的时间戳/ISO 字符串 +# --------------------------------------------------------------------------- + +def _days_ago_ts(days: int) -> float: + return (datetime.now(timezone.utc) - timedelta(days=days)).timestamp() + + +def _days_ago_iso(days: int) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# _to_timestamp +# --------------------------------------------------------------------------- + +def test_to_timestamp_int_seconds(): + assert V._to_timestamp(0) == 0.0 + assert V._to_timestamp(1719500000) == 1719500000.0 + assert V._to_timestamp("1719500000") == 1719500000.0 + + +def test_to_timestamp_millis(): + # 13 位 → 视为毫秒 + assert abs(V._to_timestamp(1719500000000) - 1719500000.0) < 1e-3 + + +def test_to_timestamp_iso(): + ts = V._to_timestamp("2024-06-15T12:00:00Z") + assert ts > 0 + # 无效字符串 → 0 + assert V._to_timestamp("not-a-date") == 0.0 + assert V._to_timestamp(None) == 0.0 + assert V._to_timestamp("") == 0.0 + + +# --------------------------------------------------------------------------- +# bin_weekly +# --------------------------------------------------------------------------- + +def test_bin_weekly_shape_and_sum(): + weeks = 4 + commits = [ + {"timestamp": _days_ago_iso(2)}, # 本周 + {"timestamp": _days_ago_iso(9)}, # 上周 + {"timestamp": _days_ago_iso(9)}, + {"timestamp": _days_ago_iso(20)}, # 3 周前 + ] + out = V.bin_weekly(commits, weeks, V._commit_time) + assert set(out.keys()) == {"labels", "counts"} + assert len(out["labels"]) == weeks + assert len(out["counts"]) == weeks + assert sum(out["counts"]) == len(commits) + # 标签是 ISO 周(形如 2026-Wxx) + assert all("-W" in lab for lab in out["labels"]) + + +def test_bin_weekly_drops_out_of_window_and_invalid(): + weeks = 3 + commits = [ + {"timestamp": _days_ago_iso(1)}, # 窗口内 + {"timestamp": _days_ago_iso(365)}, # 太早(窗口外)→ 丢弃 + {"timestamp": "garbage"}, # 非法 → 丢弃 + {}, # 无时间字段 → 丢弃 + ] + out = V.bin_weekly(commits, weeks, V._commit_time) + assert sum(out["counts"]) == 1 + + +def test_bin_weekly_issues_uses_created_at(): + weeks = 2 + issues = [ + {"created_at": _days_ago_iso(3)}, # 窗口内 + {"created_at": _days_ago_iso(40)}, # 窗口外(>2 周)→ 丢弃 + ] + out = V.bin_weekly(issues, weeks, V._issue_time) + assert sum(out["counts"]) == 1 # 只有 3 天前那条进窗口 + + +def test_bin_weekly_prs_uses_pr_created_unix(): + weeks = 2 + prs = [ + {"pr_created_unix": int(_days_ago_ts(2))}, + {"pr_created_unix": int(_days_ago_ts(50))}, + ] + out = V.bin_weekly(prs, weeks, V._pr_time) + assert sum(out["counts"]) == 1 + + +def test_bin_weekly_empty(): + out = V.bin_weekly([], 5, V._commit_time) + assert len(out["labels"]) == 5 + assert out["counts"] == [0, 0, 0, 0, 0] + + +# --------------------------------------------------------------------------- +# contribution_heatmap +# --------------------------------------------------------------------------- + +def test_heatmap_matrix_shape(): + weeks = 3 + contributors = [ + {"login": "alice", "contributions": 100}, + {"login": "bob", "contributions": 5}, + ] + commits = [ + {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}}, + {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}}, + {"timestamp": _days_ago_iso(9), "author": {"login": "bob"}}, + ] + hm = V.contribution_heatmap(contributors, commits, weeks) + assert set(hm.keys()) == {"users", "weeks", "matrix"} + assert hm["users"][:2] == ["alice", "bob"] + assert len(hm["matrix"]) == len(hm["users"]) + for row in hm["matrix"]: + assert len(row) == weeks + # alice 行总和 = 2 + alice_row = hm["matrix"][hm["users"].index("alice")] + assert sum(alice_row) == 2 + bob_row = hm["matrix"][hm["users"].index("bob")] + assert sum(bob_row) == 1 + + +def test_heatmap_includes_commit_authors_not_in_contributors(): + weeks = 2 + contributors = [{"login": "alice", "contributions": 1}] + commits = [ + {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}}, + {"timestamp": _days_ago_iso(3), "author": {"login": "carol"}}, # 不在 contributors + ] + hm = V.contribution_heatmap(contributors, commits, weeks) + assert "carol" in hm["users"] + + +def test_heatmap_top_users_cap(): + weeks = 2 + contributors = [{"login": f"u{i}", "contributions": i} for i in range(20)] + commits = [{"timestamp": _days_ago_iso(1), "author": {"login": f"u{i}"}} + for i in range(20)] + hm = V.contribution_heatmap(contributors, commits, weeks, top_users=5) + assert len(hm["users"]) <= 5 + assert len(hm["matrix"]) == len(hm["users"]) + + +def test_heatmap_empty(): + hm = V.contribution_heatmap([], [], 4) + assert hm["users"] == [] + assert hm["matrix"] == [] + assert len(hm["weeks"]) == 4 + + +# --------------------------------------------------------------------------- +# extract_paper_links +# --------------------------------------------------------------------------- + +def test_extract_arxiv_url(): + text = "See https://arxiv.org/abs/2401.00012 for details." + links = V.extract_paper_links(text) + assert len(links) == 1 + assert links[0]["type"] == "arxiv" + assert links[0]["target"] == "https://arxiv.org/abs/2401.00012" + # snippet 截取自原文上下文(窗口较窄,断言前缀即可) + assert links[0]["source_text_snippet"].startswith("See https://arxiv.org") + + +def test_extract_arxiv_pdf_url(): + text = "paper: https://arxiv.org/pdf/2305.12345.pdf" + links = V.extract_paper_links(text) + assert len(links) == 1 + # 归一成 abs 形式 + assert links[0]["target"] == "https://arxiv.org/abs/2305.12345" + + +def test_extract_arxiv_bare(): + text = "We use arXiv:2103.07018 in our method." + links = V.extract_paper_links(text) + assert any(l["target"] == "https://arxiv.org/abs/2103.07018" for l in links) + + +def test_extract_doi_url(): + text = "Cited from https://doi.org/10.1000/182" + links = V.extract_paper_links(text) + assert len(links) == 1 + assert links[0]["type"] == "doi" + assert links[0]["target"] == "https://doi.org/10.1000/182" + + +def test_extract_doi_bare(): + text = "Reference 10.1109/5.771073 shows that." + links = V.extract_paper_links(text) + assert len(links) == 1 + assert links[0]["target"] == "https://doi.org/10.1109/5.771073" + + +def test_extract_dedup_same_id(): + text = ("arxiv 1 https://arxiv.org/abs/2401.00012 " + "and again https://arxiv.org/abs/2401.00012") + links = V.extract_paper_links(text) + assert len(links) == 1 + + +def test_extract_dedup_across_doi_forms(): + # doi.org 形式与裸 DOI 视为同一条 + text = "https://doi.org/10.1000/182 and bare 10.1000/182 again" + links = V.extract_paper_links(text) + # 同一 DOI 只出现一次 + targets = [l["target"] for l in links] + assert targets.count("https://doi.org/10.1000/182") == 1 + + +def test_extract_multiple_and_order(): + text = ("first https://arxiv.org/abs/2401.00012 " + "then https://doi.org/10.1000/182") + links = V.extract_paper_links(text) + assert len(links) == 2 + # 按出现位置排序 + assert links[0]["type"] == "arxiv" + assert links[1]["type"] == "doi" + + +def test_extract_none_in_text(): + assert V.extract_paper_links("no links here at all") == [] + assert V.extract_paper_links("") == [] + + +def test_extract_strips_trailing_punct_from_doi(): + text = "see 10.1000/abc123, then more." + links = V.extract_paper_links(text) + assert links[0]["target"].endswith("/abc123") # 末尾逗号/句号被清掉 + assert not links[0]["target"].rstrip().endswith(",") + + +# --------------------------------------------------------------------------- +# classify_artifacts +# --------------------------------------------------------------------------- + +def test_classify_paper_and_ipynb(): + tree = [ + {"path": "docs/paper.pdf"}, + {"path": "notebooks/demo.ipynb"}, + ] + out = V.classify_artifacts(tree) + cats = {a["path"]: a["category"] for a in out} + assert cats["docs/paper.pdf"] == "paper" + assert cats["notebooks/demo.ipynb"] == "paper" + + +def test_classify_dataset(): + tree = [ + {"path": "data/train.csv"}, + {"path": "datasets/x.parquet"}, + ] + out = V.classify_artifacts(tree) + cats = {a["path"]: a["category"] for a in out} + assert cats["data/train.csv"] == "dataset" + + +def test_classify_model(): + tree = [ + {"path": "model/best.ckpt"}, + {"path": "models/v2.onnx"}, + ] + out = V.classify_artifacts(tree) + cats = {a["path"]: a["category"] for a in out} + assert cats["model/best.ckpt"] == "model" + assert cats["models/v2.onnx"] == "model" + + +def test_classify_benchmark(): + tree = [{"path": "benchmark/glue/run.py"}] + out = V.classify_artifacts(tree) + assert out[0]["category"] == "benchmark" + + +def test_classify_ignores_unrelated(): + tree = [ + {"path": "src/main.py"}, + {"path": "README.md"}, + {"path": "tools/util.go"}, + ] + out = V.classify_artifacts(tree) + assert out == [] # 都不命中任何类别 + + +def test_classify_dedup_same_path(): + tree = [ + {"path": "data/a.csv"}, + {"path": "data/a.csv"}, # 重复 + ] + out = V.classify_artifacts(tree) + assert len(out) == 1 + + +def test_classify_handles_name_only(): + # 没有 path 只有 name 的条目也能处理 + tree = [{"name": "paper.pdf"}] + out = V.classify_artifacts(tree) + assert len(out) == 1 + assert out[0]["category"] == "paper" + + +def test_classify_empty_and_non_dict(): + assert V.classify_artifacts([]) == [] + assert V.classify_artifacts(None) == [] + assert V.classify_artifacts(["str", 123, None]) == [] + + +def test_artifact_summary(): + arts = [ + {"path": "a.pdf", "category": "paper"}, + {"path": "b.ipynb", "category": "paper"}, + {"path": "x.csv", "category": "dataset"}, + {"path": "m.ckpt", "category": "model"}, + ] + s = V.artifact_summary(arts) + assert s == {"paper": 2, "dataset": 1, "model": 1, "benchmark": 0} + + +# --------------------------------------------------------------------------- +# render_report(不渲染 plotly,只验证 markdown 结构) +# --------------------------------------------------------------------------- + +def test_render_report_has_sections(): + result = { + "repo": "o/r", "weeks": 4, + "timeline": {"labels": ["W1", "W2", "W3", "W4"], + "commits": [1, 2, 3, 4], "issues": [0, 1, 0, 2], + "prs": [0, 0, 1, 0]}, + "heatmap": {"users": ["alice", "bob"], "weeks": ["W1", "W2"], + "matrix": [[1, 2], [0, 1]]}, + "languages": {"Python": "99%"}, + "milestones": [], + "paper_links": [{"type": "arxiv", "target": "https://arxiv.org/abs/2401.00012", + "source_text_snippet": "see arxiv"}], + "artifacts": [{"path": "p.pdf", "category": "paper", "name": "p.pdf"}], + "artifact_summary": {"paper": 1, "dataset": 0, "model": 0, "benchmark": 0}, + "meta": {"commit_count": 10, "issue_count": 3, "pr_count": 1, + "contributor_count": 2, "milestone_count": 0}, + } + md = V.render_report(result) + assert "科研成果可视化" in md + assert "o/r" in md + assert "alice" in md + assert "arxiv.org/abs/2401.00012" in md + # 含周快照表头 + assert "commits" in md + + +# --------------------------------------------------------------------------- +# 端到端(算法层):run() 复用 raw dict,不联网 +# --------------------------------------------------------------------------- + +def test_run_with_mock_raw(): + raw = { + "commits": [{"timestamp": _days_ago_iso(2), "author": {"login": "alice"}, + "message": "see https://arxiv.org/abs/2401.00012"}], + "issues": [{"created_at": _days_ago_iso(3)}], + "prs": [{"pr_created_unix": int(_days_ago_ts(4))}], + "milestones": [{"name": "v1.0", "due_on": _days_ago_iso(30)}], + "languages": {"Python": "99%"}, + "contributors": [{"login": "alice", "contributions": 1}], + "readme": "ref https://doi.org/10.1000/182 here", + "tree": [{"path": "data/x.csv"}, {"path": "paper.pdf"}], + } + result = V.run("owner", "repo", weeks=4, raw=raw) + assert result["scenario"] == "S6_research_visualization" + assert result["repo"] == "owner/repo" + assert result["weeks"] == 4 + # 论文链接同时来自 readme 和 commit message + targets = {p["target"] for p in result["paper_links"]} + assert "https://arxiv.org/abs/2401.00012" in targets + assert "https://doi.org/10.1000/182" in targets + # 产物分类 + cats = {a["category"] for a in result["artifacts"]} + assert cats == {"dataset", "paper"} + # 时间线长度 = weeks + assert len(result["timeline"]["labels"]) == 4 + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"PASS {fn.__name__}") + print(f"\nAll {len(fns)} visual tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/scripts/research/topics.py b/scripts/research/topics.py new file mode 100644 index 0000000..45ab60b --- /dev/null +++ b/scripts/research/topics.py @@ -0,0 +1,110 @@ +"""topics.py — 科研主题/技术关键词词典(S2 知识图谱与 S4 协作匹配共享)。 + +采用「关键词词典 + 字符串匹配」方式抽取主题,零 NLP/分词依赖,结果确定可复现。 +词典可扩展;覆盖 GitLink 上常见科研方向(CV/NLP/RL/DL/系统/安全/科学计算等)。 +""" +from __future__ import annotations + +import re +from collections import Counter +from typing import Iterable + +# 主题 → 触发关键词(中英文)。小写匹配。 +TOPIC_KEYWORDS: dict[str, tuple[str, ...]] = { + "machine_learning": ("机器学习", "machine learning", "监督学习", "无监督学习", + "supervised", "unsupervised", "scikit-learn", "sklearn", + "特征工程", "feature engineering", "generalization"), + "computer_vision": ("目标检测", "图像分类", "语义分割", "实例分割", "目标跟踪", + "object detection", "image classification", "semantic segmentation", + "instance segmentation", "object tracking", "yolo", "resnet", "cnn", + "图像识别", "ocr", "人脸识别", "visual", "vision"), + "nlp": ("自然语言处理", "文本分类", "机器翻译", "问答", "命名实体", + "nlp", "text classification", "machine translation", "transformer", "bert", + "gpt", "llm", "大模型", "大语言模型", "预训练", "pretrain", "分词", "tokeniz"), + "generative_ai": ("生成式", "aigc", "扩散模型", "生成模型", "文生图", "多模态", + "generative", "diffusion", "gan", "vae", "multimodal", "clip", + "对话", "chatgpt", "chat", "instruction tun"), + "reinforcement_learning": ("强化学习", "多智能体", "决策", + "reinforcement learning", "multi-agent", "ppo", "dqn", + "q-learning", "reward", "policy gradient"), + "deep_learning": ("深度学习", "神经网络", "训练", "推理", "微调", + "deep learning", "neural network", "pytorch", "tensorflow", + "mindspore", "paddle", "paddlepaddle", "inference", "fine-tun", + "backbone", "checkpoint"), + "graph_learning": ("图神经网络", "图表示学习", "知识图谱", + "graph neural", "gnn", "graph convolution", "gcn", "graphsage", + "knowledge graph", "图嵌入", "graph embed"), + "federated_learning": ("联邦学习", "隐私保护", "分布式训练", + "federated", "privacy", "distributed training"), + "speech": ("语音识别", "语音合成", "声纹", "语音", + "speech", "asr", "tts", "speaker", "voice", "声学"), + "scientific_computing": ("科学计算", "数值模拟", "高性能计算", "并行计算", + "numerical", "simulation", "hpc", "parallel", "cuda", "gpu", + "有限元", "偏微分"), + "autonomous_systems": ("自动驾驶", "机器人", "感知", "导航", "slam", + "autonomous", "robotics", "robot", "self-driving", "planning"), + "bioinformatics": ("生物信息", "蛋白质", "基因", "分子", + "bioinformatic", "genomic", "protein", "molecular", "drug"), + "time_series": ("时序", "时间序列", "预测", "序列建模", + "time series", "time-series", "forecasting", "temporal"), + "devops": ("ci/cd", "devops", "pipeline", "容器", "编排", + "docker", "kubernetes", "k8s", "jenkins", "自动化部署", "helm"), + "database": ("数据库", "存储", "索引", + "database", "sql", "nosql", "storage", "index"), + "security": ("安全", "漏洞", "加密", "隐私", + "security", "vulnerability", "crypto", "privacy", "attack"), + "data_mining": ("数据挖掘", "推荐系统", "聚类", "分类", + "data mining", "recommender", "clustering", "classification", "tf-idf"), +} + +# 编程语言关键词(用于 S4 语言匹配) +LANGUAGE_KEYWORDS: tuple[str, ...] = ( + "python", "go", "golang", "c++", "cpp", "c#", "java", "rust", "javascript", + "typescript", "julia", "r", "matlab", "scala", "swift", "kotlin", "cuda", +) + +_NON_ALNUM = re.compile(r"[^\w一-鿿+#]+") + + +def _normalize(text: str) -> str: + return (text or "").lower() + + +def extract_topics(text: str) -> list[str]: + """从一段文本中抽取命中的主题列表(去重,保序)。""" + t = _normalize(text) + if not t: + return [] + hit = [] + for topic, kws in TOPIC_KEYWORDS.items(): + for kw in kws: + if _normalize(kw) in t: + hit.append(topic) + break + return hit + + +def extract_languages(text: str) -> list[str]: + """从文本中抽取命中的编程语言(归一化别名,如 golang→go)。""" + t = _normalize(text) + if not t: + return [] + alias = {"golang": "go", "cpp": "c++", "c#": "c#", "ts": "typescript"} + out, seen = [], set() + # 按非字母数字分割后逐 token 比对,避免 'go' 误命中 'google' + tokens = set(_NON_ALNUM.sub(" ", t).split()) + for kw in LANGUAGE_KEYWORDS: + norm = _normalize(kw) + if norm in tokens and norm not in seen: + seen.add(norm) + out.append(alias.get(norm, norm)) + return out + + +def topic_counter(texts: Iterable[str]) -> Counter: + """对多段文本累计主题词频,用于热点排序(S2)。""" + c: Counter = Counter() + for t in texts: + for topic in extract_topics(t): + c[topic] += 1 + return c diff --git a/scripts/research/visual.py b/scripts/research/visual.py new file mode 100644 index 0000000..c0e335c --- /dev/null +++ b/scripts/research/visual.py @@ -0,0 +1,740 @@ +""" +S6 科研成果可视化沉淀 —— 生成交互式 Plotly HTML 报告 + +图表: 开发时间线(commits+PRs 双线)、贡献者热力图(有数据才渲染)、 + 语言占比饼图(<3% 合并为"其他")、里程碑甘特图(<2条任务跳过) +自适应布局: 空白/无效图表自动隐藏,仅展示有效数据 + +用法: + python visual.py --owner mindspore-Ecosystem --repo mindspore --weeks 26 --out ./out +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from datetime import datetime, timezone +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect as c # noqa: E402 + +# --------------------------------------------------------------------------- +# 时间解析工具 +# --------------------------------------------------------------------------- + +def _to_timestamp(value: Any) -> float: + """把 GitLink 多种时间表示统一成 epoch 秒。 + + 支持整数秒(pr_created_unix)、ISO 字符串(created_at / timestamp 字符串)。 + 无法解析返回 0.0(最远古时间,会被周分桶丢弃到「太早」一端)。 + """ + if value is None: + return 0.0 + # 整数秒(commit.timestamp 形如 "1719500000" 也可走这里) + if isinstance(value, (int, float)): + f = float(value) + # 毫秒级时间戳兜底(13 位) + return f / 1000.0 if f > 1e12 else f + s = str(value).strip() + if not s: + return 0.0 + # 纯数字字符串 + if re.fullmatch(r"\d+(\.\d+)?", s): + f = float(s) + return f / 1000.0 if f > 1e12 else f + # ISO 8601(兼容带/不带 Z、带毫秒、带时区偏移) + txt = s.replace("Z", "+00:00") + fmts = ("%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d") + for fmt in fmts: + try: + dt = datetime.strptime(txt, fmt) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + continue + return 0.0 + + +def _commit_time(commit: dict) -> float: + """提交对象取时间戳:优先 timestamp(字符串),退而 author.committed_unix。""" + ts = commit.get("timestamp") + if ts is not None: + return _to_timestamp(ts) + auth = commit.get("author") or {} + if isinstance(auth, dict): + for k in ("committed_unix", "committed_at", "time", "date"): + if auth.get(k) is not None: + return _to_timestamp(auth.get(k)) + return 0.0 + + +def _issue_time(issue: dict) -> float: + return _to_timestamp(issue.get("created_at")) + + +def _pr_time(pr: dict) -> float: + return _to_timestamp(pr.get("pr_created_unix") or pr.get("created_at")) + + +# --------------------------------------------------------------------------- +# 算法 1:按周分桶 +# --------------------------------------------------------------------------- + +def bin_weekly(items: list, weeks: int, time_getter) -> dict[str, list]: + """把带时间戳的对象按「最近 weeks 周」分桶(含本周在内的 weeks 个连续周桶)。 + + Args: + items: 待分桶对象列表。 + weeks: 保留最近多少个周桶。 + time_getter: 从单个对象取 epoch 秒的函数。 + + 返回 ``{"labels": [...], "counts": [...]}``: + - labels[i] 形如 "2026-W13"(ISO 周标签),从最近一周倒序到最老一周。 + - counts[i] 为该周命中数;时间非法或越界(早于窗口左端)的对象不计入。 + """ + weeks = max(1, int(weeks)) + now = datetime.now(timezone.utc) + # 右端边界对齐到「下周一 00:00 UTC」(不含),使窗口包含当前周在内共 weeks 周。 + # 若右端用「本周一」,当前周会被整体排除,最近一周的数据被吞掉。 + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + # Monday=0 .. Sunday=6;toordinal - weekday = 本周一,+7 = 下周一 + next_monday = today.fromordinal(today.toordinal() - today.weekday() + 7).replace(tzinfo=timezone.utc) + end_ts = next_monday.timestamp() + start_ts = end_ts - weeks * 7 * 86400 + + counts = [0] * weeks + iso_labels: list[str] = [] + for i in range(weeks): + # 桶 i 的周一 = end - (weeks-1-i) 周 + bucket_monday_ts = start_ts + i * 7 * 86400 + bucket_monday = datetime.fromtimestamp(bucket_monday_ts, tz=timezone.utc) + iso_year, iso_week, _ = bucket_monday.isocalendar() + iso_labels.append(f"{iso_year}-W{iso_week:02d}") + + for item in items: + ts = time_getter(item) + if ts <= 0: + continue + if ts < start_ts or ts >= end_ts: + continue + offset = ts - start_ts + idx = int(offset // (7 * 86400)) + if 0 <= idx < weeks: + counts[idx] += 1 + + return {"labels": iso_labels, "counts": counts} + + +# --------------------------------------------------------------------------- +# 算法 2:贡献者 × 周热力矩阵 +# --------------------------------------------------------------------------- + +def contribution_heatmap(contributors: list, commits: list, weeks: int, + top_users: int = 12) -> dict[str, list]: + """构建 top 贡献者 × 周桶的提交数矩阵。 + + Args: + contributors: GitLink contributors[](用于排序与展示名)。 + commits: GitLink commits[](含 author.login)。 + weeks: 周桶数(与 bin_weekly 同口径)。 + top_users: 矩阵最多保留多少个贡献者(按贡献数 desc)。 + + 返回 ``{"users": [login...], "weeks": [label...], "matrix": [[cnt...]]}``: + matrix[user_i][week_j] = 该用户在该周的提交数。无 contributor 信息时也按 commit 作者聚合。 + """ + weeks = max(1, int(weeks)) + # 用 bin_weekly 的同口径周标签(取贡献数排序后的 login 列表) + window = bin_weekly(commits, weeks, _commit_time) + week_labels = window["labels"] + now = datetime.now(timezone.utc) + end = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = end.fromordinal(end.toordinal() - end.weekday()).replace(tzinfo=timezone.utc) + start_ts = end.timestamp() - weeks * 7 * 86400 + + # 候选用户顺序:contributors(按 contributions desc)+ 提交里出现但不在 contributors 的作者 + ordered: list[str] = [] + seen: set[str] = set() + for contrib in contributors or []: + login = c.login_of(contrib) or "" + if login and login not in seen: + ordered.append(login) + seen.add(login) + for cm in commits or []: + auth = cm.get("author") or {} + login = c.login_of(auth) if isinstance(auth, dict) else "" + if login and login not in seen: + ordered.append(login) + seen.add(login) + + users = ordered[:top_users] + matrix = [[0] * weeks for _ in users] + user_idx = {u: i for i, u in enumerate(users)} + + for cm in commits or []: + ts = _commit_time(cm) + if ts <= 0 or ts < start_ts or ts >= end.timestamp(): + continue + auth = cm.get("author") or {} + login = c.login_of(auth) if isinstance(auth, dict) else "" + if not login or login not in user_idx: + continue + offset = ts - start_ts + idx = int(offset // (7 * 86400)) + if 0 <= idx < weeks: + matrix[user_idx[login]][idx] += 1 + + return {"users": users, "weeks": week_labels, "matrix": matrix} + + +# --------------------------------------------------------------------------- +# 算法 3:论文引用链接抽取 +# --------------------------------------------------------------------------- + +# arXiv: arxiv.org/abs/2401.00012 / arxiv.org/pdf/... / arxiv:2401.00012 +_ARXIV_RE = re.compile( + r"(?:https?://)?(?:www\.)?arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})(?:v\d+)?(?:\.pdf)?", + re.IGNORECASE, +) +_ARXIV_BARE_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})", re.IGNORECASE) +# DOI: doi.org/10.xxxx/... 或裸 10.xxxx/...(论文里的 DOI 形式) +_DOI_URL_RE = re.compile( + r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[^\s)\"'<>]+)", re.IGNORECASE, +) +_DOI_BARE_RE = re.compile( + r"\b(10\.\d{4,9}/[^\s)\"'<>]+)", re.IGNORECASE, +) + +# 在 DOI 字符串里清掉常见尾部分隔符(避免吃进句号、逗号) +_TRAILING_PUNCT = ".,;:)\"'>]" + + +def _clean_doi(doi: str) -> str: + return doi.rstrip(_TRAILING_PUNCT) + + +def _snippet(text: str, pos: int, span: int = 60) -> str: + """以匹配位置为中心截一段上下文。""" + a = max(0, pos - span // 2) + b = min(len(text), pos + span // 2) + frag = text[a:b].replace("\n", " ").strip() + return ("…" + frag) if a > 0 else frag + + +def extract_paper_links(text: str) -> list[dict[str, str]]: + """从文本里抽取 arXiv 与 DOI 论文引用链接。 + + 返回 ``[{"source_text_snippet": str, "target": url, "type": "arxiv"|"doi"}]``, + 按 (出现位置, 类型优先 arxiv) 排序,去重(同一 arxiv id / doi 只保留首次)。 + """ + if not text: + return [] + out: list[dict[str, str]] = [] + seen_arxiv: set[str] = set() + seen_doi: set[str] = set() + hits: list[tuple[int, dict[str, str]]] = [] + + for m in _ARXIV_RE.finditer(text): + aid = m.group(1) + if aid in seen_arxiv: + continue + seen_arxiv.add(aid) + hits.append((m.start(), { + "source_text_snippet": _snippet(text, m.start()), + "target": f"https://arxiv.org/abs/{aid}", + "type": "arxiv", + })) + for m in _ARXIV_BARE_RE.finditer(text): + aid = m.group(1) + if aid in seen_arxiv: + continue + seen_arxiv.add(aid) + hits.append((m.start(), { + "source_text_snippet": _snippet(text, m.start()), + "target": f"https://arxiv.org/abs/{aid}", + "type": "arxiv", + })) + for m in _DOI_URL_RE.finditer(text): + doi = _clean_doi(m.group(1)) + if doi.lower() in seen_doi: + continue + seen_doi.add(doi.lower()) + hits.append((m.start(), { + "source_text_snippet": _snippet(text, m.start()), + "target": f"https://doi.org/{doi}", + "type": "doi", + })) + for m in _DOI_BARE_RE.finditer(text): + doi = _clean_doi(m.group(1)) + if doi.lower() in seen_doi: + continue + seen_doi.add(doi.lower()) + hits.append((m.start(), { + "source_text_snippet": _snippet(text, m.start()), + "target": f"https://doi.org/{doi}", + "type": "doi", + })) + + hits.sort(key=lambda x: (x[0], 0 if x[1]["type"] == "arxiv" else 1)) + return [h[1] for h in hits] + + +# --------------------------------------------------------------------------- +# 算法 4:仓库产物分类 +# --------------------------------------------------------------------------- + +def classify_artifacts(tree_entries: list) -> list[dict[str, Any]]: + """按路径把仓库文件/目录归入科研产物类别。 + + 规则(按优先级,先匹配先归类): + - path 含 ``benchmark/`` 段 → benchmark + - path 含 ``model/`` 段或 *.ckpt/*.safetensors/*.onnx → model + - path 含 ``data/`` 段或 *.csv/*.parquet → dataset + - *.pdf / *.ipynb / paper 关键词 → paper + + 每个产物 ``{"path", "category", "name"}``。tree_entries 既可能是文件列表 + (含 path/name/type)也可能是目录项;本函数尽力取 path/name 字段。 + """ + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for entry in tree_entries or []: + if not isinstance(entry, dict): + continue + path = entry.get("path") or entry.get("name") or "" + if not path: + continue + norm = path.replace("\\", "/").lower() + name = norm.rsplit("/", 1)[-1] + category = None + # benchmark(必须含 benchmark 目录段,避免误把文件名含词的归入) + if "/benchmark/" in norm or norm.startswith("benchmark/"): + category = "benchmark" + elif "/model/" in norm or norm.startswith("model/") or name.endswith( + (".ckpt", ".safetensors", ".onnx", ".pb", ".h5", ".pt")): + category = "model" + elif "/data/" in norm or norm.startswith("data/") or name.endswith( + (".csv", ".parquet", ".npy", ".npz", ".hdf5", ".h5")): + # .h5 已先被 model 吃掉,这里主要 csv/parquet/npy + category = "dataset" + elif (name.endswith(".pdf") or name.endswith(".ipynb") + or "paper" in norm or "arxiv" in norm): + category = "paper" + if category and path not in seen: + seen.add(path) + out.append({"path": path, "category": category, + "name": name or path.rsplit("/", 1)[-1]}) + return out + + +def artifact_summary(artifacts: list[dict[str, Any]]) -> dict[str, int]: + """统计各类产物数量,返回 {paper: n, dataset: n, model: n, benchmark: n}。""" + summary: dict[str, int] = {"paper": 0, "dataset": 0, "model": 0, "benchmark": 0} + for a in artifacts or []: + cat = a.get("category") + if cat in summary: + summary[cat] += 1 + return summary + + +# --------------------------------------------------------------------------- +# 数据采集(取数层,主流程调用;单测不触达) +# --------------------------------------------------------------------------- + +def collect(owner: str, repo: str, weeks: int) -> dict[str, Any]: + """从 GitLink 取本场景所需的全部数据。""" + # commits 取够 ~weeks 周(每周按 30 条粗估,上限 max_pages=10) + cm_pages = max(2, min(10, (weeks // 3) + 1)) + commits = c.commits(owner, repo, ref="main", max_pages=cm_pages, page_size=100) + issues = c.issues_all(owner, repo, max_pages=10, page_size=50) + pullreqs = c.prs_all(owner, repo, max_pages=10, page_size=50) + milestones = c.milestones(owner, repo, state="all") + langs = c.languages(owner, repo) + contribs = c.contributors(owner, repo) + readme = c.readme(owner, repo) + tree = c.tree(owner, repo) + return { + "info": c.repo_info(owner, repo), + "commits": commits, + "issues": issues, + "prs": pullreqs, + "milestones": milestones, + "languages": langs, + "contributors": contribs, + "readme": readme, + "tree": tree, + } + + +# --------------------------------------------------------------------------- +# 主算法:组装结果 dict +# --------------------------------------------------------------------------- + +def run(owner: str, repo: str, weeks: int, raw: dict[str, Any] | None = None) -> dict[str, Any]: + """主入口:取数(或复用传入的 raw)→ 算法 → 结果 dict。""" + if raw is None: + raw = collect(owner, repo, weeks) + + commits = raw.get("commits") or [] + issues = raw.get("issues") or [] + prs = raw.get("prs") or [] + milestones = raw.get("milestones") or [] + langs = raw.get("languages") or {} + contribs = raw.get("contributors") or [] + readme = raw.get("readme") or "" + tree = raw.get("tree") or [] + + commits_ts = bin_weekly(commits, weeks, _commit_time) + issues_ts = bin_weekly(issues, weeks, _issue_time) + prs_ts = bin_weekly(prs, weeks, _pr_time) + heatmap = contribution_heatmap(contribs, commits, weeks) + + # 合并 readme + 提交信息作为论文链接抽取语料 + corpus_parts = [readme] + for cm in commits[:50]: + msg = cm.get("message") or "" + if isinstance(msg, str): + corpus_parts.append(msg) + paper_links = extract_paper_links("\n".join(corpus_parts)) + + artifacts = classify_artifacts(tree) + art_summary = artifact_summary(artifacts) + + # 里程碑甘特数据:取有 due_on 的,转成 [start, end, title] + gantt: list[dict[str, Any]] = [] + for ms in milestones: + if not isinstance(ms, dict): + continue + title = ms.get("name") or ms.get("title") or "" + due = _to_timestamp(ms.get("due_on") or ms.get("effective_date")) + start = _to_timestamp(ms.get("start_date")) + if due > 0: + gantt.append({ + "title": title, + "start": start if start > 0 else due - 14 * 86400, + "due": due, + }) + + return { + "scenario": "S6_research_visualization", + "repo": f"{owner}/{repo}", + "weeks": weeks, + "timeline": { + "labels": commits_ts["labels"], + "commits": commits_ts["counts"], + "issues": issues_ts["counts"], + "prs": prs_ts["counts"], + }, + "heatmap": heatmap, + "languages": langs, + "milestones": gantt, + "paper_links": paper_links, + "artifacts": artifacts, + "artifact_summary": art_summary, + "meta": { + "commit_count": len(commits), + "issue_count": len(issues), + "pr_count": len(prs), + "milestone_count": len(milestones), + "contributor_count": len(contribs), + }, + } + + +# --------------------------------------------------------------------------- +# 渲染:Markdown 摘要报告 +# --------------------------------------------------------------------------- + +def render_report(result: dict[str, Any]) -> str: + repo = result["repo"] + tl = result["timeline"] + weeks = result["weeks"] + meta = result["meta"] + art = result["artifact_summary"] + lines = [ + f"# 科研成果可视化沉淀报告 — {repo}\n", + f"> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」\n", + f"## 一、活跃度概览(最近 {weeks} 周)\n", + f"- 提交数: **{meta['commit_count']}**(窗口内峰值 " + f"{max(tl['commits']) if tl['commits'] else 0} 提交/周)", + f"- 新增 Issue: **{meta['issue_count']}**,新增 PR: **{meta['pr_count']}**", + f"- 贡献者: **{meta['contributor_count']}**,里程碑: **{meta['milestone_count']}**\n", + "## 二、开发节奏(最近 8 周快照)\n", + "| 周 | commits | issues | prs |", + "|----|---------|--------|-----|", + ] + tail = tl["labels"][-8:] + for i, label in enumerate(tail): + idx = len(tl["labels"]) - len(tail) + i + lines.append(f"| {label} | {tl['commits'][idx]} | {tl['issues'][idx]} | {tl['prs'][idx]} |") + + lines += ["\n## 三、核心贡献者热力(贡献者 × 周提交数)\n", + "| 贡献者 | 窗口内提交 |", + "|--------|-----------|"] + hm = result["heatmap"] + for i, user in enumerate(hm["users"][:10]): + total = sum(hm["matrix"][i]) + lines.append(f"| `{user}` | {total} |") + + lines += ["\n## 四、科研产物分类\n", + f"- 论文/笔记 (paper): **{art['paper']}**", + f"- 数据集 (dataset): **{art['dataset']}**", + f"- 模型 (model): **{art['model']}**", + f"- 基准 (benchmark): **{art['benchmark']}**\n"] + if result["paper_links"]: + lines += ["## 五、抽取到的论文引用\n", + "| 类型 | 链接 |", + "|------|------|"] + for p in result["paper_links"][:15]: + lines.append(f"| {p['type']} | {p['target']} |") + else: + lines.append("## 五、抽取到的论文引用\n\n_未在 README/提交信息中发现 arXiv 或 DOI 引用_\n") + + lines.append(f"\n_交互可视化见 visual.html(或原始数据 visual.json)_\n") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# 渲染:交互 HTML(plotly 多子图)+ JSON bundle +# --------------------------------------------------------------------------- + +def render_html(result: dict[str, Any]) -> str | None: + """构建单一交互 HTML(多子图),修复图例/空白/饼图/甘特问题。无 plotly 返回 None。""" + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError: + return None + + tl = result["timeline"] + hm = result["heatmap"] + langs = result["languages"] or {} + gantt = result["milestones"] + + # ---- 预处理:时间线截断到最近有数据的周 ---- + labels = tl["labels"] + commits_arr = tl["commits"] + prs_arr = tl["prs"] + # 找到最后一个非零周,只展示到该周 + 前面 buffer + last_data_idx = -1 + for i in range(len(labels) - 1, -1, -1): + if commits_arr[i] > 0 or prs_arr[i] > 0: + last_data_idx = i + break + if last_data_idx < 0: + last_data_idx = len(labels) - 1 + # 截取:从最早有数据的周的前 4 周开始,到最新周,最少 12 周 + first_nonzero = -1 + for i in range(len(labels)): + if commits_arr[i] > 0 or prs_arr[i] > 0: + first_nonzero = i + break + if first_nonzero >= 0: + start_idx = max(0, first_nonzero - 4) + end_idx = min(len(labels), last_data_idx + 2) + if end_idx - start_idx < 12: + start_idx = max(0, end_idx - 12) + else: + start_idx = 0 + end_idx = len(labels) + trim_labels = labels[start_idx:end_idx] + trim_commits = commits_arr[start_idx:end_idx] + trim_prs = prs_arr[start_idx:end_idx] + + # 简化周标签:去掉年份,只显示 "W01", "W05" ... + def _short_week(label: str) -> str: + parts = label.split("-W") + return f"W{parts[1]}" if len(parts) == 2 else label + trim_labels_short = [_short_week(lb) for lb in trim_labels] + # 计算 tick 间隔:标签数 <= 12 则全部显示,否则每 2~4 个显示一个 + n_labels = len(trim_labels_short) + if n_labels <= 12: + tick_step = 1 + elif n_labels <= 20: + tick_step = 2 + else: + tick_step = max(2, n_labels // 10) + tick_vals = trim_labels_short[::tick_step] + tick_text = tick_vals + + # ---- 语言数据预处理:<3% 合并为「其他」---- + pie_labels, pie_values, pie_text = [], [], [] + if langs: + lang_items = [] + for k, v in langs.items(): + s = str(v).strip().rstrip("%") + try: + lang_items.append((k, float(s))) + except ValueError: + lang_items.append((k, 0.0)) + total = sum(x[1] for x in lang_items) or 1.0 + pie_labels, pie_values, pie_text = [], [], [] + other_val = 0.0 + for name, val in lang_items: + pct = val / total * 100 + if pct < 3.0: + other_val += val + else: + pie_labels.append(name) + pie_values.append(val) + pie_text.append(f"{name}: {pct:.1f}% ({val:.1f}% lines)") + if other_val > 0: + pie_labels.append("其他") + pie_values.append(other_val) + pie_text.append(f"其他: {other_val/total*100:.1f}% ({other_val:.1f}% lines)") + + # ---- 决定布局(跳过空白图)---- + has_heatmap = bool(hm["users"]) and any(sum(row) > 0 for row in hm["matrix"]) + has_pie = bool(langs and len(pie_labels) > 1) + has_gantt = len(gantt) >= 2 + + rows = 1 + if has_heatmap: + rows += 1 + if has_pie: + rows += 1 + if has_gantt: + rows += 1 + + # ---- 构建 specs(pie 需要 domain 类型,其他用 xy)---- + spec_list = [{"type": "xy"}] # row 1: timeline (always present) + if has_heatmap: + spec_list.append({"type": "xy"}) + if has_pie: + spec_list.append({"type": "domain"}) + if has_gantt: + spec_list.append({"type": "xy"}) + + fig = make_subplots( + rows=rows, cols=1, + specs=[[s] for s in spec_list], + vertical_spacing=0.10, + row_heights=[max(0.35, 1.0 / rows)] * rows, + ) + cur = 1 + + # ---- 1) 开发时间线 ---- + fig.add_trace(go.Scatter( + x=trim_labels_short, y=trim_commits, name="Commits (提交)", + mode="lines+markers", line=dict(color="#4F6BED", width=2), + marker=dict(size=5), + ), row=cur, col=1) + fig.add_trace(go.Scatter( + x=trim_labels_short, y=trim_prs, name="PRs (合并请求)", + mode="lines+markers", line=dict(color="#2EC4B6", width=2), + marker=dict(size=5), + ), row=cur, col=1) + fig.update_yaxes(title_text="数量 (条)", row=cur, col=1) + fig.update_xaxes(tickangle=0, tickvals=tick_vals, ticktext=tick_text, + tickfont=dict(size=10), row=cur, col=1) + cur += 1 + + # ---- 2) 贡献热力图(有数据才画)---- + if has_heatmap: + hm_weeks_short = [_short_week(w) for w in hm["weeks"]] + hm_n = len(hm_weeks_short) + hm_step = 1 if hm_n <= 12 else (2 if hm_n <= 20 else max(2, hm_n // 10)) + hm_tick_vals = hm_weeks_short[::hm_step] + fig.add_trace(go.Heatmap( + z=hm["matrix"], x=hm_weeks_short, y=hm["users"], + colorscale="Blues", name="提交数", + colorbar=dict(title="次", len=0.4, y=0.5 + 0.3 / rows), + ), row=cur, col=1) + fig.update_xaxes(tickangle=0, tickvals=hm_tick_vals, ticktext=hm_tick_vals, + tickfont=dict(size=9), row=cur, col=1) + cur += 1 + + # ---- 3) 语言饼图 ---- + if has_pie: + fig.add_trace(go.Pie( + labels=pie_labels, values=pie_values, + text=pie_text, textinfo="text", + textfont=dict(size=11), + marker=dict(colors=["#4F6BED", "#2EC4B6", "#F26B5E", "#F59E0B", "#06B6D4", + "#8B5CF6", "#94A3B8", "#64748B"]), + ), row=cur, col=1) + cur += 1 + + # ---- 4) 里程碑甘特 ---- + if has_gantt: + for g in gantt[:10]: + title = g["title"] or "(milestone)" + start_str = datetime.fromtimestamp(g["start"], tz=timezone.utc).strftime("%m-%d") + due_str = datetime.fromtimestamp(g["due"], tz=timezone.utc).strftime("%m-%d") + hover = f"{title}
{start_str} → {due_str}" + fig.add_trace(go.Bar( + x=[g["due"] - g["start"]], y=[title], + base=g["start"], orientation="h", + marker=dict(color="#ffa15a", line=dict(color="#d97706", width=1)), + hovertemplate=hover, showlegend=False, + width=0.5, + ), row=cur, col=1) + fig.update_xaxes(title_text="日期 (UTC)", row=cur, col=1, + tickformat="%m-%d", dtick=7 * 86400 * 1000) + cur += 1 + + fig.update_layout( + title=dict( + text=f"科研成果可视化 — {result['repo']}(最近 {result['weeks']} 周)", + font=dict(size=16), + ), + height=max(500, rows * 340), + legend=dict(orientation="h", yanchor="top", y=-0.12, x=0.5, xanchor="center", + font=dict(size=11)), + margin=dict(l=60, r=40, t=60, b=60), + hovermode="x unified", + ) + + return fig.to_html(full_html=True, include_plotlyjs="cdn", + default_width="100%", default_height=f"{max(500, rows * 340)}px") + + +# --------------------------------------------------------------------------- +# 主入口 +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="S6 科研成果可视化沉淀") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--weeks", type=int, default=26, help="回溯多少周(默认 26)") + ap.add_argument("--out", help="输出目录(写 visual.html + visual.json + report.md);" + "省略则打印 JSON") + args = ap.parse_args() + + result = run(args.owner, args.repo, args.weeks) + + if args.out: + os.makedirs(args.out, exist_ok=True) + # 原始数据 bundle(供前端二次开发) + with open(os.path.join(args.out, "visual.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f: + f.write(render_report(result)) + + html = render_html(result) + if html is not None: + with open(os.path.join(args.out, "visual.html"), "w", encoding="utf-8") as f: + f.write(html) + print(f"✓ S6 可视化完成 → {args.out}/visual.html | visual.json | report.md") + else: + print(f"✓ S6 可视化完成(无 plotly,已降级)→ {args.out}/visual.json | report.md") + print(" 提示:pip install plotly 后可生成交互 HTML") + meta = result["meta"] + art = result["artifact_summary"] + print(f" commits={meta['commit_count']} issues={meta['issue_count']} " + f"prs={meta['pr_count']} 贡献者={meta['contributor_count']}") + print(f" 产物 paper={art['paper']} dataset={art['dataset']} " + f"model={art['model']} benchmark={art['benchmark']}") + print(f" 论文引用: {len(result['paper_links'])} 条") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/shortcuts/_template/template.go b/shortcuts/_template/template.go new file mode 100644 index 0000000..cd8e3f0 --- /dev/null +++ b/shortcuts/_template/template.go @@ -0,0 +1,88 @@ +// Package PLACEHOLDER implements shortcuts for PLACEHOLDER management. +// +// To create a new shortcut domain: +// 1. Copy this file to shortcuts/PLACEHOLDER/PLACEHOLDER.go +// 2. Replace all "PLACEHOLDER" with your domain name +// 3. Implement your shortcuts in the Shortcuts() function +// 4. Register in shortcuts/register.go +// 5. Add tests in PLACEHOLDER_test.go +package PLACEHOLDER + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns all shortcuts for this domain. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List PLACEHOLDERs", + 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()+"/PLACEHOLDERs", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a PLACEHOLDER", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); 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", ctx.RepoPath()+"/PLACEHOLDERs", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a PLACEHOLDER", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PLACEHOLDER ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/PLACEHOLDERs/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go index 1f1908f..6f0e172 100644 --- a/shortcuts/branch/branch_test.go +++ b/shortcuts/branch/branch_test.go @@ -1,218 +1,126 @@ package branch import ( - "encoding/json" "net/http" - "net/http/httptest" + "strings" "testing" - "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - shortcuts := Shortcuts() - for _, s := range shortcuts { - if s.Name == name { - return s - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- list --- - func TestBranchList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/branches") { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "branches": []interface{}{ + map[string]interface{}{ + "name": "master", + "protected": false, + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, []interface{}{ - map[string]interface{}{"name": "master"}, - map[string]interface{}{"name": "develop"}, - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { t.Fatalf("list failed: %v", err) } } -// --- create --- - func TestBranchCreate(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 != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"name": "feature-x"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "name": "feature-1", + "protected": false, + }) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "feature-1", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) if err != nil { t.Fatalf("create failed: %v", err) } -} - -func TestBranchCreateDefaultFrom(t *testing.T) { - // When 'from' is not set, it defaults to "master" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"name": "feature-y"}) - })) - defer server.Close() - - err := runShortcut(t, server, "create", map[string]string{"name": "feature-y"}) - if err != nil { - t.Fatalf("create failed: %v", err) + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) } } -// --- delete --- - func TestBranchDelete(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches/delete.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "deleted"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "old-branch", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) if err != nil { t.Fatalf("delete failed: %v", err) } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) + } } -// --- protect --- - func TestBranchProtect(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/protected_branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "protected"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "protect", map[string]string{"name": "master"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "master", + }) + err := common.RunShortcut(t, Shortcuts(), "protect", ctx) if err != nil { t.Fatalf("protect failed: %v", err) } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) + } } -// --- unprotect --- - func TestBranchUnprotect(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 != "/owner/repo/protected_branches/master.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "unprotected"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "master", + }) + err := common.RunShortcut(t, Shortcuts(), "unprotect", ctx) if err != nil { t.Fatalf("unprotect failed: %v", err) } -} - -// --- HTTP error paths --- - -func TestBranchListHTTPError(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, "list", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestBranchCreateHTTPError(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", map[string]string{"name": "feature-x"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestBranchDeleteHTTPError(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", map[string]string{"name": "old-branch"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestBranchProtectHTTPError(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, "protect", map[string]string{"name": "master"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestBranchUnprotectHTTPError(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, "unprotect", map[string]string{"name": "master"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + if requestMethod != "DELETE" { + t.Errorf("expected DELETE, got %s", requestMethod) } } diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index 6794c47..b291290 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -47,12 +47,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { build, _ := ctx.RequireArg("build") stage := ctx.Arg("stage") step := ctx.Arg("step") - if stage == "" { - stage = "1" - } - if step == "" { - step = "1" - } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) if err != nil { return err @@ -96,6 +90,41 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + newCIToggleShortcut("enable", "Enable CI for a repository"), + newCIToggleShortcut("disable", "Disable CI for a repository"), + { + Name: "authorize", + Description: "Check CI authorization status", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/ci_authorize", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +// newCIToggleShortcut 生成 enable/disable CI 的 shortcut。 +func newCIToggleShortcut(action, description string) *common.Shortcut { + return &common.Shortcut{ + Name: action, + Description: description, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("POST", + fmt.Sprintf("/v1/%s/%s/actions/%s", ctx.Owner, ctx.Repo, action), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, } } diff --git a/shortcuts/ci/ci_test.go b/shortcuts/ci/ci_test.go index 98f257b..79f86f3 100644 --- a/shortcuts/ci/ci_test.go +++ b/shortcuts/ci/ci_test.go @@ -1,182 +1,170 @@ package ci 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 runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(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 writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- builds --- - func TestCIBuilds(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/builds.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "builds": []interface{}{ + map[string]interface{}{ + "id": float64(10), + "status": "success", + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, []interface{}{ - map[string]interface{}{"number": float64(1), "status": "success"}, - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "builds", map[string]string{"page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "builds", ctx) if err != nil { t.Fatalf("builds failed: %v", err) } } -// --- logs --- - func TestCILogs(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/builds/5/logs/1/1.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/builds/10/logs/1/1.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "build_id": float64(10), + "stage": float64(1), + "step": float64(1), + "lines": []interface{}{"Building..."}, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{"log": "Build output..."}) - })) + }) defer server.Close() - err := runShortcut(t, server, "logs", map[string]string{"build": "5", "stage": "1", "step": "1"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "build": "10", + "stage": "1", + "step": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "logs", ctx) if err != nil { t.Fatalf("logs failed: %v", err) } } -func TestCILogsDefaults(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/builds/3/logs/1/1.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"log": "output"}) - })) - defer server.Close() - - err := runShortcut(t, server, "logs", map[string]string{"build": "3"}) - if err != nil { - t.Fatalf("logs with defaults failed: %v", err) - } -} - -// --- restart --- - func TestCIRestart(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/builds/7/restart.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "restarted"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "restart", map[string]string{"build": "7"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "build": "10", + }) + err := common.RunShortcut(t, Shortcuts(), "restart", ctx) if err != nil { t.Fatalf("restart failed: %v", err) } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) + } } -// --- stop --- - func TestCIStop(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 != "/owner/repo/builds/7/stop.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "stopped"}) - })) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "stop", map[string]string{"build": "7"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "build": "10", + }) + err := common.RunShortcut(t, Shortcuts(), "stop", ctx) if err != nil { t.Fatalf("stop failed: %v", err) } -} - -// --- HTTP error paths --- - -func TestCIBuildsHTTPError(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, "builds", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + if requestMethod != "DELETE" { + t.Errorf("expected DELETE, got %s", requestMethod) } } -func TestCILogsHTTPError(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() +func TestCIToggle(t *testing.T) { + tests := []struct { + name string + shortcut string + wantMethod string + wantPath string + }{ + {"enable", "enable", "POST", "/v1/owner/repo/actions/enable.json"}, + {"disable", "disable", "POST", "/v1/owner/repo/actions/disable.json"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requestMethod string + var requestPath string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + requestPath = r.URL.Path + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) + defer server.Close() - err := runShortcut(t, server, "logs", map[string]string{"build": "5"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), tt.shortcut, ctx) + if err != nil { + t.Fatalf("%s failed: %v", tt.shortcut, err) + } + if requestMethod != tt.wantMethod { + t.Errorf("expected %s, got %s", tt.wantMethod, requestMethod) + } + if requestPath != tt.wantPath { + t.Errorf("expected %s, got %s", tt.wantPath, requestPath) + } + }) } } -func TestCIRestartHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +func TestCIAuthorize(t *testing.T) { + var requestMethod string + var requestPath string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + requestPath = r.URL.Path + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + }) defer server.Close() - err := runShortcut(t, server, "restart", map[string]string{"build": "7"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestCIStopHTTPError(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, "stop", map[string]string{"build": "7"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "authorize", ctx) + if err != nil { + t.Fatalf("authorize failed: %v", err) + } + if requestMethod != "GET" { + t.Errorf("expected GET, got %s", requestMethod) + } + if requestPath != "/owner/repo/ci_authorize.json" { + t.Errorf("expected /owner/repo/ci_authorize.json, got %s", requestPath) } } diff --git a/shortcuts/common/testutil.go b/shortcuts/common/testutil.go new file mode 100644 index 0000000..600d9b2 --- /dev/null +++ b/shortcuts/common/testutil.go @@ -0,0 +1,95 @@ +package common + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// NewTestServer creates an httptest.Server with the given handler. +func NewTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +// NewTestContext creates a RuntimeContext pointing at the test server. +func NewTestContext(t *testing.T, server *httptest.Server, owner, repo string, args map[string]string) *RuntimeContext { + t.Helper() + return &RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: owner, + Repo: repo, + Format: "json", + Args: args, + } +} + +// RunShortcut finds a shortcut by name and runs it with the given context. +func RunShortcut(t *testing.T, shortcuts []*Shortcut, name string, ctx *RuntimeContext) error { + t.Helper() + for _, s := range shortcuts { + if s.Name == name { + return s.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +// DecodeJSON decodes the request body into a map. +func DecodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +// DecodeForm decodes a form-encoded request body into a map with typed values. +func DecodeForm(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + parsed, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("failed to parse form body: %v", err) + } + result := make(map[string]interface{}) + for k, vs := range parsed { + if len(vs) == 1 { + result[k] = vs[0] + } else { + result[k] = vs + } + } + return result +} + +// WriteJSON writes a JSON response. +func WriteJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +// AssertEqual compares two values. +func AssertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052a..761beef 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,7 +2,6 @@ package common import ( "encoding/json" - "errors" "fmt" "net/url" @@ -90,6 +89,21 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value return ctx.Client.Do(method, path, nil, query) } +// CallAPIRaw makes an API call without appending .json suffix. +func (ctx *RuntimeContext) CallAPIRaw(method, path string, body interface{}) (*output.Envelope, error) { + return ctx.Client.DoRaw(method, path, body, nil) +} + +// CallAPIRawWithQuery makes an API call with query parameters, without .json suffix. +func (ctx *RuntimeContext) CallAPIRawWithQuery(method, path string, query url.Values) (*output.Envelope, error) { + return ctx.Client.DoRaw(method, path, nil, query) +} + +// CallAPIRawForm makes an API call with form-encoded body, without .json suffix. +func (ctx *RuntimeContext) CallAPIRawForm(method, path string, body url.Values) (*output.Envelope, error) { + return ctx.Client.DoForm(method, path, body, nil) +} + // PaginateAll fetches all pages. func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) { return ctx.Client.PaginateAll(path, params) @@ -122,7 +136,11 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) + tr := ctx.Tr + if tr == nil { + tr = i18n.Default() + } + return "", fmt.Errorf("%s", tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/explore/explore.go b/shortcuts/explore/explore.go new file mode 100644 index 0000000..cfc2d39 --- /dev/null +++ b/shortcuts/explore/explore.go @@ -0,0 +1,96 @@ +package explore + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts — gitlink-cli explore 域:GitLink 官方「分类精选 / 探索」数据源。 +// +// explore +categories 列出全部领域分类(id + name) +// explore +pinned --category 深度学习 --limit 30 该分类下的精选项目(pinned=d) +// +// 数据源(公开、免鉴权): +// - GET /api/project_categories.json -> {"project_categories":[{id,name},...]} +// - GET /api/projects.json?pinned=d&category_id=N&limit=M +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + _ = translators // reserved for future i18n (descriptions currently literal) + return []*common.Shortcut{ + { + Name: "categories", + Description: "List GitLink project categories (id + name)", + Run: func(ctx *common.RuntimeContext) error { + env, err := ctx.CallAPI("GET", "/project_categories", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "pinned", + Description: "List pinned (curated) projects in a category", + Flags: []common.Flag{ + {Name: "category", Short: "c", Usage: "category name or id (e.g. 深度学习 or 32)", Required: true}, + {Name: "limit", Short: "l", Usage: "number of results", Default: "20"}, + {Name: "page", Short: "p", Usage: "page number", Default: "1"}, + }, + Run: func(ctx *common.RuntimeContext) error { + catID, err := resolveCategoryID(ctx, ctx.Arg("category")) + if err != nil { + return err + } + q := url.Values{} + q.Set("pinned", "d") + q.Set("category_id", catID) + q.Set("limit", ctx.Arg("limit")) + q.Set("page", ctx.Arg("page")) + env, err := ctx.CallAPIWithQuery("GET", "/projects", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +// resolveCategoryID:纯数字直接返回;否则拉分类表按 name 匹配出 id。 +func resolveCategoryID(ctx *common.RuntimeContext, cat string) (string, error) { + if cat == "" { + return "", fmt.Errorf("category is required (name or id)") + } + if _, err := strconv.Atoi(cat); err == nil { + return cat, nil + } + env, err := ctx.CallAPI("GET", "/project_categories", nil) + if err != nil { + return "", fmt.Errorf("resolve category %q: %w", cat, err) + } + data, _ := env.Data.(map[string]interface{}) + if data == nil { + return "", fmt.Errorf("resolve category %q: unexpected response shape", cat) + } + cats, _ := data["project_categories"].([]interface{}) + for _, c := range cats { + cm, ok := c.(map[string]interface{}) + if !ok { + continue + } + if fmt.Sprint(cm["name"]) == cat { + return fmt.Sprint(cm["id"]), nil + } + } + return "", fmt.Errorf("category %q not found; run `gitlink-cli explore +categories` to list", cat) +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/explore/explore_test.go b/shortcuts/explore/explore_test.go new file mode 100644 index 0000000..2f36224 --- /dev/null +++ b/shortcuts/explore/explore_test.go @@ -0,0 +1,75 @@ +package explore + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestExploreCategories(t *testing.T) { + var path string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + common.WriteJSON(t, w, map[string]interface{}{ + "project_categories": []interface{}{ + map[string]interface{}{"id": 32, "name": "深度学习"}, + }, + }) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", nil) + if err := common.RunShortcut(t, Shortcuts(), "categories", ctx); err != nil { + t.Fatalf("explore +categories failed: %v", err) + } + if !strings.Contains(path, "/project_categories") { + t.Errorf("expected /project_categories path, got %s", path) + } +} + +func TestExplorePinnedByID(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + common.WriteJSON(t, w, map[string]interface{}{"total_count": 1, "projects": []interface{}{}}) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{"category": "32", "limit": "5"}) + if err := common.RunShortcut(t, Shortcuts(), "pinned", ctx); err != nil { + t.Fatalf("explore +pinned failed: %v", err) + } + for _, want := range []string{"pinned=d", "category_id=32", "limit=5"} { + if !strings.Contains(query, want) { + t.Errorf("query missing %q; got %s", want, query) + } + } +} + +func TestExplorePinnedByName(t *testing.T) { + var pinnedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "project_categories") { + common.WriteJSON(t, w, map[string]interface{}{ + "project_categories": []interface{}{ + map[string]interface{}{"id": 32, "name": "深度学习"}, + }, + }) + return + } + pinnedQuery = r.URL.RawQuery + common.WriteJSON(t, w, map[string]interface{}{"total_count": 1, "projects": []interface{}{}}) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{"category": "深度学习"}) + if err := common.RunShortcut(t, Shortcuts(), "pinned", ctx); err != nil { + t.Fatalf("explore +pinned by name failed: %v", err) + } + if !strings.Contains(pinnedQuery, "category_id=32") { + t.Errorf("expected name 深度学习 -> category_id=32; got %s", pinnedQuery) + } +} diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go new file mode 100644 index 0000000..8f146d6 --- /dev/null +++ b/shortcuts/file/file.go @@ -0,0 +1,215 @@ +package file + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns all shortcuts for repository file operations. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "browse", + Description: "Browse repository directory tree or file details", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File or directory path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", path) + q.Set("ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "Get file content (auto-decodes base64)", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", path) + q.Set("ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a new file in the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "File content (will be base64 encoded)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + message := ctx.Arg("message") + if message == "" { + message = fmt.Sprintf("Add %s", path) + } + body := map[string]interface{}{ + "filepath": path, + "base64_filepath": base64.StdEncoding.EncodeToString([]byte(path)), + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + "branch": ctx.Arg("branch"), + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update an existing file in the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "New file content (will be base64 encoded)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + {Name: "sha", Usage: "File SHA (required, fetch automatically if not provided)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + sha := ctx.Arg("sha") + if sha == "" { + fetchedSHA, err := fetchFileSHA(ctx, path) + if err != nil { + return fmt.Errorf("请使用 --sha 手动指定(获取文件 SHA 失败: %w)", err) + } + sha = fetchedSHA + } + message := ctx.Arg("message") + if message == "" { + message = fmt.Sprintf("Update %s", path) + } + body := map[string]interface{}{ + "filepath": path, + "base64_filepath": base64.StdEncoding.EncodeToString([]byte(path)), + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "sha": sha, + "message": message, + "branch": ctx.Arg("branch"), + } + env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/update_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a file from the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + {Name: "sha", Usage: "File SHA (required, fetch automatically if not provided)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + sha := ctx.Arg("sha") + if sha == "" { + fetchedSHA, err := fetchFileSHA(ctx, path) + if err != nil { + return fmt.Errorf("请使用 --sha 手动指定(获取文件 SHA 失败: %w)", err) + } + sha = fetchedSHA + } + message := ctx.Arg("message") + if message == "" { + message = fmt.Sprintf("Delete %s", path) + } + body := map[string]interface{}{ + "filepath": path, + "base64_filepath": base64.StdEncoding.EncodeToString([]byte(path)), + "sha": sha, + "message": message, + "branch": ctx.Arg("branch"), + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func fetchFileSHA(ctx *common.RuntimeContext, path string) (string, error) { + q := url.Values{} + q.Set("filepath", path) + q.Set("ref", ctx.Arg("branch")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return "", err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected response format") + } + sha, _ := data["sha"].(string) + if sha == "" { + return "", fmt.Errorf("SHA not found in response") + } + return sha, nil +} diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go new file mode 100644 index 0000000..9daeb0b --- /dev/null +++ b/shortcuts/file/file_test.go @@ -0,0 +1,107 @@ +package file + +import ( + "encoding/base64" + "net/http" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFileBrowse(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/sub_entries.json" { + if r.URL.Query().Get("filepath") != "src/main.go" { + t.Fatalf("expected filepath=src/main.go, got %s", r.URL.Query().Get("filepath")) + } + common.WriteJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "main.go", + "type": "file", + "sha": "abc123", + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "path": "src/main.go", + "ref": "master", + }) + err := common.RunShortcut(t, Shortcuts(), "browse", ctx) + if err != nil { + t.Fatalf("browse failed: %v", err) + } +} + +func TestFileCreate(t *testing.T) { + var createPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" { + createPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "创建成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "path": "hello.txt", + "content": "Hello World", + "branch": "master", + "messages": "", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + common.AssertEqual(t, createPayload["filepath"], "hello.txt") + common.AssertEqual(t, createPayload["branch"], "master") + + decoded, err := base64.StdEncoding.DecodeString(createPayload["content"].(string)) + if err != nil { + t.Fatalf("failed to decode base64 content: %v", err) + } + common.AssertEqual(t, string(decoded), "Hello World") +} + +func TestFileDeleteFetchesSHA(t *testing.T) { + var deletePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/sub_entries.json": + common.WriteJSON(t, w, map[string]interface{}{ + "sha": "fetchedsha123", + }) + case r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json": + deletePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "删除成功", + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "path": "old-file.txt", + "branch": "master", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) + } + + common.AssertEqual(t, deletePayload["sha"], "fetchedsha123") + common.AssertEqual(t, deletePayload["filepath"], "old-file.txt") +} diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 4d69f2f..c544053 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -1,394 +1,17 @@ package issue import ( - "encoding/csv" - "fmt" - "os" - "strconv" - "strings" - + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const closedIssueStatusID = 5 +const closeIssueStatusID = 5 -type batchCloseResult struct { - Number string `json:"number" yaml:"number"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchCloseSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchCloseResult `json:"results" yaml:"results"` -} - -func newBatchCloseShortcut() *common.Shortcut { +func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-close", - Description: "Close multiple issues by issue numbers or a CSV file", - Flags: []common.Flag{ - {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, - {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, - {Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"}, - }, - Run: runBatchClose, + Description: tr.T("cmd.issue.batch_close.short"), + Flags: batchStateFlags(tr), + Run: func(ctx *common.RuntimeContext) error { return runBatchStateChange(ctx, "close", closeIssueStatusID) }, } } - -func runBatchClose(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) - if err != nil { - return err - } - if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCloseSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Total: len(numbers), - Results: make([]batchCloseResult, 0, len(numbers)), - } - - for _, number := range numbers { - result := batchCloseResult{Number: number, Action: "close"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := closeIssue(ctx, number); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "closed" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) - } - return nil -} - -func closeIssue(ctx *common.RuntimeContext, number string) error { - current, err := fetchExistingIssue(ctx, number) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - "status_id": closedIssueStatusID, - } - if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("close issue: %w", err) - } - return nil -} - -func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { - numbers, err := parseIssueNumbers(numbersValue) - if err != nil { - return nil, err - } - if csvPath == "" { - return numbers, nil - } - - csvNumbers, err := readIssueNumbersFromCSV(csvPath) - if err != nil { - return nil, err - } - return mergeIssueNumbers(numbers, csvNumbers), nil -} - -func parseIssueNumbers(value string) ([]string, error) { - if strings.TrimSpace(value) == "" { - return nil, nil - } - return normalizeIssueNumbers(strings.Split(value, ",")) -} - -func readIssueNumbersFromCSV(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("read issue numbers from CSV: %w", err) - } - defer file.Close() - - reader := csv.NewReader(file) - reader.TrimLeadingSpace = true - records, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) - } - if len(records) == 0 { - return nil, nil - } - - numberColumn := -1 - startRow := 0 - for i, cell := range records[0] { - switch strings.ToLower(strings.TrimSpace(cell)) { - case "number", "issue_number", "project_issues_index": - numberColumn = i - startRow = 1 - } - } - if numberColumn == -1 { - numberColumn = 0 - } - - values := make([]string, 0, len(records)-startRow) - for _, record := range records[startRow:] { - if numberColumn >= len(record) { - continue - } - values = append(values, record[numberColumn]) - } - return normalizeIssueNumbers(values) -} - -func normalizeIssueNumbers(values []string) ([]string, error) { - numbers := make([]string, 0, len(values)) - seen := map[string]bool{} - for _, value := range values { - number := strings.TrimSpace(value) - if number == "" { - continue - } - if _, err := strconv.ParseInt(number, 10, 64); err != nil { - return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) - } - if seen[number] { - continue - } - seen[number] = true - numbers = append(numbers, number) - } - return numbers, nil -} - -func mergeIssueNumbers(values ...[]string) []string { - merged := []string{} - seen := map[string]bool{} - for _, numbers := range values { - for _, number := range numbers { - if seen[number] { - continue - } - seen[number] = true - merged = append(merged, number) - } - } - return merged -} - -func parseBool(value string) bool { - parsed, err := strconv.ParseBool(strings.TrimSpace(value)) - return err == nil && parsed -} - -type batchMaintenanceDryRun struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Action string `json:"action" yaml:"action"` - Method string `json:"method" yaml:"method"` - Path string `json:"path" yaml:"path"` - Body map[string]interface{} `json:"body" yaml:"body"` -} - -func newBatchUpdateShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-update", - Description: "Batch update issue metadata by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "status-id", Usage: "Issue status ID"}, - {Name: "priority-id", Usage: "Issue priority ID"}, - {Name: "milestone-id", Usage: "Issue milestone ID"}, - {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, - {Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"}, - {Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"}, - }, - Run: runBatchUpdate, - } -} - -func newBatchDeleteShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-delete", - Description: "Batch delete issues by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"}, - {Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"}, - }, - Run: runBatchDelete, - } -} - -func runBatchUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - body, err := buildBatchUpdateBody(ctx) - if err != nil { - return err - } - path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) - if parseBool(ctx.Arg("dry-run")) { - return ctx.OutputData(batchMaintenanceDryRun{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: true, - Action: "batch_update_issues", - Method: "PATCH", - Path: path, - Body: body, - }) - } - env, err := ctx.CallAPI("PATCH", path, body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runBatchDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return err - } - body := map[string]interface{}{"ids": ids} - path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)) - dryRun := parseBool(ctx.Arg("dry-run")) - if dryRun { - return ctx.OutputData(batchMaintenanceDryRun{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: true, - Action: "batch_delete_issues", - Method: "DELETE", - Path: path, - Body: body, - }) - } - if !parseBool(ctx.Arg("yes")) { - return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm") - } - env, err := ctx.CallAPI("DELETE", path, body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) { - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return nil, err - } - body := map[string]interface{}{"ids": ids} - changed := false - if value := ctx.Arg("status-id"); value != "" { - id, err := parseSingleIntID(value, "status-id") - if err != nil { - return nil, err - } - body["status_id"] = id - changed = true - } - if value := ctx.Arg("priority-id"); value != "" { - id, err := parseSingleIntID(value, "priority-id") - if err != nil { - return nil, err - } - body["priority_id"] = id - changed = true - } - if value := ctx.Arg("milestone-id"); value != "" { - id, err := parseSingleIntID(value, "milestone-id") - if err != nil { - return nil, err - } - body["milestone_id"] = id - changed = true - } - if value := ctx.Arg("tag-ids"); value != "" { - ids, err := parseIntIDList(value, "tag-ids") - if err != nil { - return nil, err - } - body["issue_tag_ids"] = ids - changed = true - } - if value := ctx.Arg("assigner-ids"); value != "" { - ids, err := parseIntIDList(value, "assigner-ids") - if err != nil { - return nil, err - } - body["assigner_ids"] = ids - changed = true - } - if !changed { - return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids") - } - return body, nil -} - -func parseSingleIntID(value, field string) (int, error) { - value = strings.TrimSpace(value) - if value == "" { - return 0, fmt.Errorf("%s cannot be empty", field) - } - id, err := strconv.Atoi(value) - if err != nil || id <= 0 { - return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value) - } - return id, nil -} - -func parseIntIDList(value, field string) ([]int, error) { - if strings.TrimSpace(value) == "" { - return nil, fmt.Errorf("%s cannot be empty", field) - } - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - seen := map[int]bool{} - for _, part := range parts { - id, err := parseSingleIntID(part, field) - if err != nil { - return nil, err - } - if seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - return ids, nil -} diff --git a/shortcuts/issue/batch_assign.go b/shortcuts/issue/batch_assign.go new file mode 100644 index 0000000..87a87dc --- /dev/null +++ b/shortcuts/issue/batch_assign.go @@ -0,0 +1,116 @@ +package issue + +import ( + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchAssignShortcut(tr *i18n.Translator) *common.Shortcut { + flags := []common.Flag{ + {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch_assign.numbers")}, + {Name: "from", Usage: tr.T("flag.issue.batch_assign.csv")}, + {Name: "search", Usage: tr.T("flag.issue.batch.search")}, + {Name: "state", Usage: tr.T("flag.issue.batch.state")}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.batch_assign.assignee")}, + } + flags = append(flags, batchRuntimeFlags(tr)...) + return &common.Shortcut{ + Name: "batch-assign", + Description: tr.T("cmd.issue.batch_assign.short"), + Flags: flags, + Run: runBatchAssign, + } +} + +func runBatchAssign(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + csvPath := ctx.Arg("from") + opts := parseBatchOptions(ctx) + + if csvPath != "" { + headers, rows, err := ReadCSV(csvPath) + if err != nil { + return err + } + numberCol := FindColumn(headers, "number", "issue_number", "project_issues_index") + if numberCol == -1 { + return fmt.Errorf("CSV 缺少编号列(number/issue_number/project_issues_index)") + } + assigneeCol := FindColumn(headers, "assignee", "assignee_id", "assigned_to_id") + if assigneeCol == -1 { + return fmt.Errorf("CSV 缺少经办人列(assignee/assignee_id/assigned_to_id)") + } + + // 暂存原始经办人字符串,resolve 推迟到逐条 callback 内执行, + // 这样 --dry-run 不会触发任何 GET /users/search。 + assigneeMap := make(map[string]string, len(rows)) + numbers := make([]string, 0, len(rows)) + numberSeen := make(map[string]bool) + for _, row := range rows { + if numberCol >= len(row) || assigneeCol >= len(row) { + continue + } + num := strings.TrimSpace(row[numberCol]) + arg := strings.TrimSpace(row[assigneeCol]) + if num == "" || arg == "" { + continue + } + if _, exists := assigneeMap[num]; exists { + fmt.Fprintf(os.Stderr, "警告:issue #%s 在 CSV 中出现多次,仅使用最后一次的经办人\n", num) + } + assigneeMap[num] = arg + if !numberSeen[num] { + numbers = append(numbers, num) + numberSeen[num] = true + } + } + if len(assigneeMap) == 0 { + return fmt.Errorf("no valid entries in CSV") + } + + assignFn := func(c *common.RuntimeContext, number string) error { + arg := assigneeMap[number] + aid, err := ResolveUserID(c, arg) + if err != nil { + return fmt.Errorf("assignee %q: %w", arg, err) + } + return assignIssue(c, number, aid) + } + _, err = RunBatch(ctx, numbers, "assign", opts, assignFn) + return err + } + + numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), "", ctx.Arg("search")) + if err != nil { + return err + } + + assigneeArg := ctx.Arg("assignee") + if assigneeArg == "" { + return fmt.Errorf("--assignee is required in uniform mode") + } + + // 把 ResolveUserID 推迟到逐条 callback,使 --dry-run 不会调用 GET /users/search; + // 解析失败改为按条记录在 BatchResult.Error 中。 + assigneeFn := func(c *common.RuntimeContext, number string) error { + aid, err := ResolveUserID(c, assigneeArg) + if err != nil { + return fmt.Errorf("assignee %q: %w", assigneeArg, err) + } + return assignIssue(c, number, aid) + } + _, err = RunBatch(ctx, numbers, "assign", opts, assigneeFn) + return err +} + +func assignIssue(ctx *common.RuntimeContext, number string, assigneeID int) error { + return patchIssue(ctx, number, map[string]interface{}{"assigner_ids": []int{assigneeID}}, "assign") +} + diff --git a/shortcuts/issue/batch_common.go b/shortcuts/issue/batch_common.go new file mode 100644 index 0000000..01be3af --- /dev/null +++ b/shortcuts/issue/batch_common.go @@ -0,0 +1,697 @@ +package issue + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// batch 命令共享开关的默认值。 +const ( + defaultBatchMaxItems = 100 + defaultBatchDelayMs = 0 +) + +// BatchOptions 承载所有 batch_* 命令共享的运行时配置。 +type BatchOptions struct { + DryRun bool + Confirm bool + MaxItems int + DelayMs int +} + +// parseBatchOptions 从 RuntimeContext 解析 batch 命令的共享开关。 +func parseBatchOptions(ctx *common.RuntimeContext) BatchOptions { + return BatchOptions{ + DryRun: parseBool(ctx.Arg("dry-run")), + Confirm: parseBool(ctx.Arg("confirm")), + MaxItems: parseIntArg(ctx, "max", defaultBatchMaxItems), + DelayMs: parseIntArg(ctx, "delay", defaultBatchDelayMs), + } +} + +// batchStateFlags 返回 batch 状态变更命令(close、open)共用的 flag 列表。 +func batchStateFlags(tr *i18n.Translator) []common.Flag { + return []common.Flag{ + {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch.numbers")}, + {Name: "from", Usage: tr.T("flag.issue.batch.from")}, + {Name: "search", Usage: tr.T("flag.issue.batch.search")}, + {Name: "state", Usage: tr.T("flag.issue.batch.state")}, + {Name: "label", Usage: tr.T("flag.issue.batch.label")}, + {Name: "confirm", Usage: tr.T("flag.issue.batch.confirm"), Bool: true, Default: "false"}, + {Name: "max", Usage: tr.T("flag.issue.batch.max"), Default: strconv.Itoa(defaultBatchMaxItems)}, + {Name: "delay", Usage: tr.T("flag.issue.batch.delay"), Default: strconv.Itoa(defaultBatchDelayMs)}, + {Name: "dry-run", Usage: tr.T("flag.issue.batch.dry_run"), Bool: true, Default: "false"}, + } +} + +// batchRuntimeFlags 返回各 batch 命令共用的运行时 flag(dry-run/confirm/max/delay)。 +func batchRuntimeFlags(tr *i18n.Translator) []common.Flag { + return []common.Flag{ + {Name: "dry-run", Usage: tr.T("flag.issue.batch.dry_run"), Bool: true, Default: "false"}, + {Name: "confirm", Usage: tr.T("flag.issue.batch.confirm"), Bool: true, Default: "false"}, + {Name: "max", Usage: tr.T("flag.issue.batch.max"), Default: strconv.Itoa(defaultBatchMaxItems)}, + {Name: "delay", Usage: tr.T("flag.issue.batch.delay"), Default: strconv.Itoa(defaultBatchDelayMs)}, + } +} + +// runBatchStateChange 是 batch 状态变更命令(close、open)共享的 Run 实现。 +// action 形参同时用作 RunBatch 的操作名与 patchIssue 的错误前缀。 +func runBatchStateChange(ctx *common.RuntimeContext, action string, statusID int) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), ctx.Arg("from"), ctx.Arg("search")) + if err != nil { + return err + } + fn := func(c *common.RuntimeContext, number string) error { + return patchIssue(c, number, map[string]interface{}{"status_id": statusID}, action) + } + _, err = RunBatch(ctx, numbers, action, parseBatchOptions(ctx), fn) + return err +} + +// BatchResult 记录单条 issue 上一次 batch 操作的结果。 +// ID 在 close/open/assign/label/update 中是 issue 编号,在 create 中是 "row-N"。 +type BatchResult struct { + ID string `json:"id" yaml:"id"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +// BatchSummary 汇总一次 batch 操作的总体结果。 +type BatchSummary struct { + Repository string `json:"repository" yaml:"repository"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Truncated bool `json:"truncated,omitempty" yaml:"truncated,omitempty"` + Results []BatchResult `json:"results" yaml:"results"` +} + +// parseBool 把字符串解析为 bool。空串或解析失败时返回 false。 +func parseBool(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed +} + +// parseIntArg 把指定 flag 解析为 int,空值或解析失败时回退到 defaultVal。 +func parseIntArg(ctx *common.RuntimeContext, name string, defaultVal int) int { + val := ctx.Arg(name) + if val == "" { + return defaultVal + } + v, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + return v +} + +// ReadCSV 读取 CSV 文件并返回表头与数据行(不含表头)。 +// 自动剥离首行首列单元格的 UTF-8 BOM。 +func ReadCSV(path string) ([]string, [][]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) == 0 { + return nil, nil, fmt.Errorf("CSV file is empty or has no data rows") + } + + // 去除表头首列单元格的 UTF-8 BOM + records[0][0] = strings.TrimLeft(records[0][0], "\uFEFF") + + return records[0], records[1:], nil +} + +// FindColumn 在 headers 中查找与任一 alias 忽略大小写、忽略首尾空格后匹配的列下标。 +// 找不到时返回 -1。 +func FindColumn(headers []string, aliases ...string) int { + for i, header := range headers { + normalized := strings.ToLower(strings.TrimSpace(header)) + for _, alias := range aliases { + if normalized == strings.ToLower(strings.TrimSpace(alias)) { + return i + } + } + } + return -1 +} + +// parseIssueNumbers 把逗号分隔的 issue 编号字符串拆分为列表并做归一化。 +func parseIssueNumbers(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeIssueNumbers(strings.Split(value, ",")) +} + +// normalizeIssueNumbers 对编号列表做去空白、去重,并校验每项必须是合法整数。 +func normalizeIssueNumbers(values []string) ([]string, error) { + numbers := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + number := strings.TrimSpace(value) + if number == "" { + continue + } + if _, err := strconv.ParseInt(number, 10, 64); err != nil { + return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) + } + if seen[number] { + continue + } + seen[number] = true + numbers = append(numbers, number) + } + return numbers, nil +} + +// mergeIssueNumbers 把多组 issue 编号合并为单一列表,并做跨组去重,保持首次出现顺序。 +func mergeIssueNumbers(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, numbers := range values { + for _, number := range numbers { + if seen[number] { + continue + } + seen[number] = true + merged = append(merged, number) + } + } + return merged +} + +// readIssueNumbersFromCSV 读取 CSV 文件,定位 issue 编号所在列(number / issue_number, +// 大小写不敏感),返回该列中所有非空编号。文件不存在或解析失败时返回错误; +// 空文件(无数据行)返回 (nil, nil),表示无来源而非错误。 +func readIssueNumbersFromCSV(path string) ([]string, error) { + headers, rows, err := ReadCSV(path) + if err != nil { + // 区分「文件不存在/读失败」(返回错误)与「无数据行」。 + // ReadCSV 对空文件返回 "CSV file is empty or has no data rows",视作无来源。 + if strings.Contains(err.Error(), "empty or has no data rows") { + return nil, nil + } + return nil, err + } + + col := FindColumn(headers, "number", "issue_number") + if col < 0 { + return nil, fmt.Errorf("CSV missing issue number column (expected \"number\" or \"issue_number\")") + } + + var numbers []string + for _, row := range rows { + if col >= len(row) { + continue // 短行跳过 + } + value := strings.TrimSpace(row[col]) + if value == "" { + continue + } + numbers = append(numbers, value) + } + if len(numbers) == 0 { + return nil, nil + } + return normalizeIssueNumbers(numbers) +} + +// collectIssueNumbers 从命令行编号(逗号分隔)与 CSV 文件两个来源汇总 issue 编号, +// 合并去重后返回。两者均可省略;任一来源出错(非法编号、CSV 读失败)立即返回错误。 +func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { + var sources [][]string + + if nums, err := parseIssueNumbers(numbersValue); err != nil { + return nil, err + } else { + sources = append(sources, nums) + } + + if csvPath != "" { + csvNumbers, err := readIssueNumbersFromCSV(csvPath) + if err != nil { + return nil, err + } + sources = append(sources, csvNumbers) + } + + merged := mergeIssueNumbers(sources...) + if len(merged) == 0 { + return nil, fmt.Errorf("no issue numbers provided: pass --numbers or --from") + } + return merged, nil +} + +// ResolveIssueNumbers 从三个来源(--numbers、--from CSV、--search)汇总 issue 编号, +// 合并去重后返回。三者均可省略,但至少需有一个非空来源。 +func ResolveIssueNumbers(ctx *common.RuntimeContext, numbersValue, csvPath, searchKeyword string) ([]string, error) { + var allNumbers [][]string + + // 1. 来自 --numbers(逗号分隔字符串) + nums, err := parseIssueNumbers(numbersValue) + if err != nil { + return nil, err + } + allNumbers = append(allNumbers, nums) + + // 2. 来自 --from 指定的 CSV + if csvPath != "" { + headers, rows, err := ReadCSV(csvPath) + if err != nil { + return nil, err + } + col := FindColumn(headers, "number", "issue_number", "project_issues_index") + if col == -1 { + return nil, fmt.Errorf("no matching column (number/issue_number/project_issues_index) in CSV: %s", csvPath) + } + csvNums := make([]string, 0, len(rows)) + for _, row := range rows { + if col < len(row) { + csvNums = append(csvNums, row[col]) + } + } + csvNums, err = normalizeIssueNumbers(csvNums) + if err != nil { + return nil, err + } + allNumbers = append(allNumbers, csvNums) + } + + // 3. 来自 --search 关键词 + if searchKeyword != "" { + searchNums, err := searchIssues(ctx, searchKeyword) + if err != nil { + return nil, err + } + allNumbers = append(allNumbers, searchNums) + } + + result := mergeIssueNumbers(allNumbers...) + if len(result) == 0 { + return nil, fmt.Errorf("no issue numbers found") + } + return result, nil +} + +// searchIssues 调用 v1 issues 搜索接口并提取匹配项的 issue 编号。 +// API 单次最多返回 100 条;若响应中 total_count 表明匹配更多,会向 stderr 输出警告。 +func searchIssues(ctx *common.RuntimeContext, keyword string) ([]string, error) { + q := url.Values{} + q.Set("search", keyword) + q.Set("limit", "100") + if state := ctx.Arg("state"); state != "" { + q.Set("state", state) + } + if label := ctx.Arg("label"); label != "" { + q.Set("label", label) + } + + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) + if err != nil { + return nil, fmt.Errorf("search issues: %w", err) + } + + rawMap, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("search issues: unexpected response format") + } + + dataField, ok := rawMap["data"] + if !ok { + return nil, fmt.Errorf("search issues: no data in response") + } + + issues, err := parseDataArray(dataField) + if err != nil { + return nil, fmt.Errorf("search issues: parse data: %w", err) + } + + // 当响应声明的总数大于本页返回时给出警告 + if total, ok := rawMap["total_count"].(float64); ok && int(total) > len(issues) { + fmt.Fprintf(os.Stderr, "警告:搜索 %q 匹配 %d 个 issue,但 API 一次最多返回 100 个,结果可能不完整。请用 --state/--label 缩小范围\n", keyword, int(total)) + } + + numbers := make([]string, 0, len(issues)) + for _, item := range issues { + issue, ok := item.(map[string]interface{}) + if !ok { + continue + } + // PATCH 接口要求项目内编号(project-local number),不接受全局 DB id。 + // 仅在 "number" 缺失时回退到 "iid"(Redmine 命名),不向 "id" 回退, + // 否则会把全局 id 透传给 PATCH,接口必然 404。 + var id string + if v, ok := issue["number"]; ok { + id = fmt.Sprintf("%v", v) + } else if v, ok := issue["iid"]; ok { + id = fmt.Sprintf("%v", v) + } + if id != "" && id != "0" { + numbers = append(numbers, id) + } + } + + return normalizeIssueNumbers(numbers) +} + +// 名称→ID 解析器的进程级缓存。 +// labelCache / milestoneCache 以 "{owner}/{repo}" 为键做仓库级隔离, +// 避免在同一进程内切换仓库时产生脏数据。所有 map 的读写都在 resolverCacheMu 保护下。 +var ( + resolverCacheMu sync.Mutex + userCache map[string]int + labelCache map[string]map[string]int // 仓库路径 → label 名称 → label ID + milestoneCache map[string]map[string]int // 仓库路径 → milestone 名称 → milestone ID +) + +// parseDataArray 把 API 响应中的 Data 字段统一解析为 []interface{}, +// 兼容 client.Do 返回的 []interface{}、json.RawMessage、JSON 字符串三种形态。 +func parseDataArray(data interface{}) ([]interface{}, error) { + switch d := data.(type) { + case []interface{}: + return d, nil + case json.RawMessage: + var items []interface{} + if err := json.Unmarshal([]byte(d), &items); err != nil { + return nil, err + } + return items, nil + case string: + var items []interface{} + if err := json.Unmarshal([]byte(d), &items); err != nil { + return nil, err + } + return items, nil + default: + return nil, fmt.Errorf("unexpected data type %T", data) + } +} + +// ResolveUserID 把用户登录名解析为数字 user ID。 +// 若 name 本身是数字则直接返回;否则调用 GET /users/search?q={name}, +// 把返回的全部用户按 login→id 缓存,并返回匹配的 ID。 +func ResolveUserID(ctx *common.RuntimeContext, name string) (int, error) { + name = strings.TrimSpace(name) + if id, err := strconv.Atoi(name); err == nil { + return id, nil + } + + // 命中缓存直接返回 + resolverCacheMu.Lock() + if id, ok := userCache[name]; ok { + resolverCacheMu.Unlock() + return id, nil + } + resolverCacheMu.Unlock() + + // 缓存未命中,调用用户搜索 API + q := url.Values{} + q.Set("q", name) + env, err := ctx.CallAPIWithQuery("GET", "/users/search", q) + if err != nil { + return 0, fmt.Errorf("resolve user: %w", err) + } + + users, err := parseDataArray(env.Data) + if err != nil { + return 0, fmt.Errorf("resolve user: parse data: %w", err) + } + + // 把搜索返回的全部用户写进缓存,便于后续按 login 命中 + resolverCacheMu.Lock() + if userCache == nil { + userCache = make(map[string]int, len(users)) + } + for _, item := range users { + u, ok := item.(map[string]interface{}) + if !ok { + continue + } + login, _ := u["login"].(string) + id := getMapInt(u, "id") + if login != "" && id > 0 { + userCache[login] = id + } + } + id, ok := userCache[name] + resolverCacheMu.Unlock() + + if !ok { + return 0, fmt.Errorf("user %q not found", name) + } + return id, nil +} + +// ResolveLabelID 把 label 名称解析为数字 label ID。 +// 若 name 本身是数字则直接返回;否则首次按当前仓库拉取全部 label +// (GET /{owner}/{repo}/labels,v0 前缀)并按仓库维度缓存,后续直接走缓存。 +func ResolveLabelID(ctx *common.RuntimeContext, name string) (int, error) { + if id, err := strconv.Atoi(name); err == nil { + return id, nil + } + + repoKey := fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo) + + // 命中当前仓库的 label 缓存 + resolverCacheMu.Lock() + if repoCache, ok := labelCache[repoKey]; ok { + id, found := repoCache[name] + resolverCacheMu.Unlock() + if !found { + return 0, fmt.Errorf("label %q not found", name) + } + return id, nil + } + resolverCacheMu.Unlock() + + // 缓存未命中,从 API 拉取该仓库的全部 label + env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/labels", ctx.Owner, ctx.Repo), nil) + if err != nil { + return 0, fmt.Errorf("resolve label: %w", err) + } + + // API 返回 {"status":0, "issue_tags":[...], ...},client.Do 把整个响应包在 envelope 里, + // 所以 env.Data 是包含 issue_tags 键的 map,需要先提取 issue_tags 再解析数组。 + rawMap, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("resolve label: unexpected response type %T", env.Data) + } + itemsRaw, ok := rawMap["issue_tags"] + if !ok { + return 0, fmt.Errorf("resolve label: response missing issue_tags field") + } + items, err := parseDataArray(itemsRaw) + if err != nil { + return 0, fmt.Errorf("resolve label: parse issue_tags: %w", err) + } + + // 在锁内把当前仓库的 label 全量写入按 repoKey 隔离的缓存 + resolverCacheMu.Lock() + if labelCache == nil { + labelCache = make(map[string]map[string]int) + } + repoCache := make(map[string]int, len(items)) + for _, item := range items { + l, ok := item.(map[string]interface{}) + if !ok { + continue + } + labelName, _ := l["name"].(string) + id := getMapInt(l, "id") + if labelName != "" && id > 0 { + repoCache[labelName] = id + } + } + labelCache[repoKey] = repoCache + id, ok := repoCache[name] + resolverCacheMu.Unlock() + + if !ok { + return 0, fmt.Errorf("label %q not found", name) + } + return id, nil +} + +// ResolveMilestoneID 把 milestone 名称解析为数字 milestone ID。 +// 若 name 本身是数字则直接返回;否则首次按当前仓库拉取全部 milestone +// (GET /v1/{owner}/{repo}/milestones)并按仓库维度缓存,后续直接走缓存。 +func ResolveMilestoneID(ctx *common.RuntimeContext, name string) (int, error) { + if id, err := strconv.Atoi(name); err == nil { + return id, nil + } + + repoKey := fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo) + + // 命中当前仓库的 milestone 缓存 + resolverCacheMu.Lock() + if repoCache, ok := milestoneCache[repoKey]; ok { + id, found := repoCache[name] + resolverCacheMu.Unlock() + if !found { + return 0, fmt.Errorf("milestone %q not found", name) + } + return id, nil + } + resolverCacheMu.Unlock() + + // 缓存未命中,从 API 拉取该仓库的全部 milestone + env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/milestones", nil) + if err != nil { + return 0, fmt.Errorf("resolve milestone: %w", err) + } + + // API 返回 {"closed_milestone_count":0, "opening_milestone_count":0, "total_count":0, "milestones":[...]}, + // client.Do 把整个响应包在 envelope 里,所以 env.Data 是包含 milestones 键的 map, + // 需要先提取 milestones 再解析数组。 + rawMap, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("resolve milestone: unexpected response type %T", env.Data) + } + itemsRaw, ok := rawMap["milestones"] + if !ok { + return 0, fmt.Errorf("resolve milestone: response missing milestones field") + } + items, err := parseDataArray(itemsRaw) + if err != nil { + return 0, fmt.Errorf("resolve milestone: parse milestones: %w", err) + } + + // 在锁内把当前仓库的 milestone 全量写入按 repoKey 隔离的缓存 + resolverCacheMu.Lock() + if milestoneCache == nil { + milestoneCache = make(map[string]map[string]int) + } + repoCache := make(map[string]int, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + milestoneName, _ := m["name"].(string) + id := getMapInt(m, "id") + if milestoneName != "" && id > 0 { + repoCache[milestoneName] = id + } + } + milestoneCache[repoKey] = repoCache + id, ok := repoCache[name] + resolverCacheMu.Unlock() + + if !ok { + return 0, fmt.Errorf("milestone %q not found", name) + } + return id, nil +} + +// RunBatch 在一组 issue 编号上执行批量操作,集成 dry-run、节流、确认门、--max 截断。 +// fn 是逐条执行的操作回调,dry-run 模式下不会被调用。 +// 返回值同时包含汇总和错误:完全成功时 error 为 nil;存在失败或截断时附带描述性错误。 +func RunBatch(ctx *common.RuntimeContext, numbers []string, action string, opts BatchOptions, fn func(ctx *common.RuntimeContext, number string) error) (*BatchSummary, error) { + // 确认门:dry-run 直接放行;非 dry-run 必须显式 --confirm 或环境变量 + if !opts.DryRun && !opts.Confirm && os.Getenv("GITLINK_CONFIRM_BATCH") != "true" { + return nil, fmt.Errorf("请添加 --confirm 确认执行,或使用 --dry-run 预览。也可设置 GITLINK_CONFIRM_BATCH=true 环境变量跳过此检查") + } + + // --max 截断:超过上限时取前 N 条并标记 truncated + truncated := false + if opts.MaxItems > 0 && len(numbers) > opts.MaxItems { + fmt.Fprintf(os.Stderr, "警告:已按 --max=%d 截断,从 %d 个减少到 %d 个\n", opts.MaxItems, len(numbers), opts.MaxItems) + numbers = numbers[:opts.MaxItems] + truncated = true + } + + summary := &BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: opts.DryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + Truncated: truncated, + } + + for i, number := range numbers { + // 仅在两次请求之间节流,跳过第一条 + if opts.DelayMs > 0 && i > 0 { + time.Sleep(time.Duration(opts.DelayMs) * time.Millisecond) + } + + result := BatchResult{ID: number, Action: action} + + if opts.DryRun { + result.Status = "dry_run" + summary.Succeeded++ + } else { + if err := fn(ctx, number); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "success" + summary.Succeeded++ + } + } + summary.Results = append(summary.Results, result) + } + + // 先输出汇总,再根据失败/截断状态决定是否返回错误 + if err := ctx.OutputData(summary); err != nil { + return summary, err + } + + // 失败与截断同时出现时,错误信息合并提示 + if summary.Failed > 0 && summary.Truncated { + return summary, fmt.Errorf("%d of %d issue(s) failed to %s (results truncated to %d)", summary.Failed, summary.Total, action, opts.MaxItems) + } + if summary.Failed > 0 { + return summary, fmt.Errorf("%d of %d issue(s) failed to %s", summary.Failed, summary.Total, action) + } + if summary.Truncated { + return summary, fmt.Errorf("results truncated to %d issues", opts.MaxItems) + } + + return summary, nil +} + +// patchIssue 先读取 issue 当前数据,再以 subject/description 为基础合并 extraFields 后发送 PATCH。 +// action 用于包裹 PATCH 阶段错误(形如 "close issue: %w"),便于定位失败操作。 +func patchIssue(ctx *common.RuntimeContext, number string, extraFields map[string]interface{}, action string) error { + current, err := fetchIssueData(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + for k, v := range extraFields { + body[k] = v + } + _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return fmt.Errorf("%s issue: %w", action, err) + } + return nil +} diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go new file mode 100644 index 0000000..60e39a2 --- /dev/null +++ b/shortcuts/issue/batch_create.go @@ -0,0 +1,161 @@ +package issue + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchCreateShortcut(tr *i18n.Translator) *common.Shortcut { + flags := []common.Flag{ + {Name: "from", Short: "f", Usage: tr.T("flag.issue.batch_create.csv"), Required: true}, + {Name: "print-schema", Usage: tr.T("flag.issue.batch_create.print_schema"), Bool: true, Default: "false"}, + } + flags = append(flags, batchRuntimeFlags(tr)...) + return &common.Shortcut{ + Name: "batch-create", + Description: tr.T("cmd.issue.batch_create.short"), + Flags: flags, + Run: runBatchCreate, + } +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + if parseBool(ctx.Arg("print-schema")) { + fmt.Println("title,body,assignee,milestone,label,priority") + return nil + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + opts := parseBatchOptions(ctx) + if !opts.DryRun && !opts.Confirm && os.Getenv("GITLINK_CONFIRM_BATCH") != "true" { + return fmt.Errorf("请添加 --confirm 确认执行,或使用 --dry-run 预览。也可设置 GITLINK_CONFIRM_BATCH=true 环境变量跳过此检查") + } + + headers, rows, err := ReadCSV(ctx.Arg("from")) + if err != nil { + return err + } + + titleCol := FindColumn(headers, "title", "subject") + if titleCol == -1 { + return fmt.Errorf("CSV 缺少标题列(title/subject)") + } + bodyCol := FindColumn(headers, "body", "description") + assigneeCol := FindColumn(headers, "assignee", "assignee_id") + milestoneCol := FindColumn(headers, "milestone", "fixed_version_id", "milestone_id") + labelCol := FindColumn(headers, "label", "labels") + priorityCol := FindColumn(headers, "priority", "priority_id") + + truncated := false + if opts.MaxItems > 0 && len(rows) > opts.MaxItems { + fmt.Fprintf(os.Stderr, "警告:CSV 有 %d 行,已按 --max=%d 截断\n", len(rows), opts.MaxItems) + rows = rows[:opts.MaxItems] + truncated = true + } + + summary := &BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: opts.DryRun, + Total: len(rows), + Truncated: truncated, + Results: make([]BatchResult, 0, len(rows)), + } + + for i, row := range rows { + result := BatchResult{ID: fmt.Sprintf("row-%d", i+1), Action: "create"} + if opts.DryRun { + result.Status = "dry_run" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if opts.DelayMs > 0 && i > 0 { + time.Sleep(time.Duration(opts.DelayMs) * time.Millisecond) + } + + if err := createIssueFromRow(ctx, row, titleCol, bodyCol, assigneeCol, milestoneCol, labelCol, priorityCol); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "success" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total) + } + if truncated { + return fmt.Errorf("结果已截断,仅处理了 %d 个 Issue", summary.Total) + } + return nil +} + +func createIssueFromRow(ctx *common.RuntimeContext, row []string, titleCol, bodyCol, assigneeCol, milestoneCol, labelCol, priorityCol int) error { + title := getCell(row, titleCol) + if title == "" { + return fmt.Errorf("empty title") + } + + body := map[string]interface{}{"subject": title, "status_id": 1, "priority_id": 2, "done_ratio": 0} + if desc := getCell(row, bodyCol); desc != "" { + body["description"] = desc + } + if assignee := getCell(row, assigneeCol); assignee != "" { + id, err := ResolveUserID(ctx, assignee) + if err != nil { + return fmt.Errorf("assignee %q: %w", assignee, err) + } + body["assigner_ids"] = []int{id} + } + if milestone := getCell(row, milestoneCol); milestone != "" { + if id, err := strconv.Atoi(milestone); err == nil { + body["milestone_id"] = id + } else { + id, err := ResolveMilestoneID(ctx, milestone) + if err != nil { + return fmt.Errorf("milestone %q: %w", milestone, err) + } + body["milestone_id"] = id + } + } + if labels := getCell(row, labelCol); labels != "" { + labelIDs, err := resolveLabelArgs(ctx, labels, "") + if err != nil { + return fmt.Errorf("label %q: %w", labels, err) + } + body["issue_tag_ids"] = labelIDs + } + if pri := getCell(row, priorityCol); pri != "" { + pid, err := strconv.Atoi(pri) + if err != nil { + return fmt.Errorf("priority %q: must be a numeric priority_id", pri) + } + body["priority_id"] = pid + } + _, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) + if err != nil { + return fmt.Errorf("create issue: %w", err) + } + return nil +} + +func getCell(row []string, col int) string { + if col < 0 || col >= len(row) { + return "" + } + return strings.TrimSpace(row[col]) +} diff --git a/shortcuts/issue/batch_delete.go b/shortcuts/issue/batch_delete.go new file mode 100644 index 0000000..6f01229 --- /dev/null +++ b/shortcuts/issue/batch_delete.go @@ -0,0 +1,95 @@ +package issue + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchDeleteShortcut(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: tr.T("cmd.issue.batch_delete.short"), + Flags: []common.Flag{ + {Name: "ids", Short: "i", Usage: tr.T("flag.issue.batch_delete.ids"), Required: true}, + {Name: "dry-run", Usage: tr.T("flag.issue.batch_delete.dry_run"), Bool: true, Default: "false"}, + {Name: "confirm", Usage: tr.T("flag.issue.batch_delete.confirm"), Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + idsValue, err := ctx.RequireArg("ids") + if err != nil { + return err + } + ids, err := parseCommaInts(idsValue) + if err != nil { + return err + } + + dryRun := parseBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-delete", + "dry_run": true, + "ids": ids, + "message": "使用 --confirm 执行实际删除", + }) + } + + if !parseBool(ctx.Arg("confirm")) { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-delete", + "dry_run": true, + "ids": ids, + "message": "批量删除是危险操作,请添加 --confirm 标志确认删除", + }) + } + + _, err = ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", map[string]interface{}{ + "ids": ids, + }) + if err != nil { + return err + } + + return ctx.OutputData(map[string]interface{}{ + "message": fmt.Sprintf("成功删除 %d 个 issue", len(ids)), + "ids": ids, + }) +} + +// parseCommaInts 把逗号分隔的字符串解析为唯一整数切片。 +func parseCommaInts(value string) ([]int, error) { + parts := strings.Split(value, ",") + ids := make([]int, 0, len(parts)) + seen := map[int]bool{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + id, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("无效的 ID: %q", p) + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("请提供至少一个 ID") + } + return ids, nil +} diff --git a/shortcuts/issue/batch_label.go b/shortcuts/issue/batch_label.go new file mode 100644 index 0000000..08c4ca3 --- /dev/null +++ b/shortcuts/issue/batch_label.go @@ -0,0 +1,160 @@ +package issue + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut { + flags := []common.Flag{ + {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch_label.numbers")}, + {Name: "from", Usage: tr.T("flag.issue.batch_label.csv")}, + {Name: "search", Usage: tr.T("flag.issue.batch.search")}, + {Name: "state", Usage: tr.T("flag.issue.batch.state")}, + {Name: "action", Short: "a", Usage: tr.T("flag.issue.batch_label.action"), Required: true}, + {Name: "labels", Short: "l", Usage: tr.T("flag.issue.batch_label.labels")}, + {Name: "label-ids", Usage: tr.T("flag.issue.batch_label.label_ids")}, + } + flags = append(flags, batchRuntimeFlags(tr)...) + return &common.Shortcut{ + Name: "batch-label", + Description: tr.T("cmd.issue.batch_label.short"), + Flags: flags, + Run: runBatchLabel, + } +} + +func runBatchLabel(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + action := strings.ToLower(strings.TrimSpace(ctx.Arg("action"))) + switch action { + case "add", "remove", "set": + default: + return fmt.Errorf("invalid --action %q: must be add, remove, or set", action) + } + + labelNames := ctx.Arg("labels") + labelIDsStr := ctx.Arg("label-ids") + if labelNames == "" && labelIDsStr == "" { + return fmt.Errorf("either --labels or --label-ids is required") + } + if labelNames != "" && labelIDsStr != "" { + return fmt.Errorf("--labels and --label-ids are mutually exclusive") + } + + numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), ctx.Arg("from"), ctx.Arg("search")) + if err != nil { + return err + } + + opts := parseBatchOptions(ctx) + + // 把 label 名称解析推迟到逐条 callback,使 --dry-run 不会触发 + // 用于预热 label 缓存的 API 调用(如 GET /labels)。 + labelFn := func(c *common.RuntimeContext, number string) error { + labelIDs, err := resolveLabelArgs(c, labelNames, labelIDsStr) + if err != nil { + return err + } + return manageIssueLabels(c, number, action, labelIDs) + } + _, err = RunBatch(ctx, numbers, "label-"+action, opts, labelFn) + return err +} + +func manageIssueLabels(ctx *common.RuntimeContext, number string, action string, newIDs []int) error { + current, err := fetchIssueData(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + existingIDs := current.LabelIDs + if existingIDs == nil { + existingIDs = []int{} + } + + var finalIDs []int + switch action { + case "add": + finalIDs = mergeLabelIDs(existingIDs, newIDs) + case "remove": + finalIDs = removeLabelIDs(existingIDs, newIDs) + case "set": + finalIDs = newIDs + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + "issue_tag_ids": finalIDs, + } + _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return fmt.Errorf("update labels: %w", err) + } + return nil +} + +func mergeLabelIDs(existing, new []int) []int { + has := map[int]bool{} + for _, id := range existing { + has[id] = true + } + for _, id := range new { + if !has[id] { + existing = append(existing, id) + has[id] = true + } + } + return existing +} + +func removeLabelIDs(existing, toRemove []int) []int { + remove := map[int]bool{} + for _, id := range toRemove { + remove[id] = true + } + result := make([]int, 0, len(existing)) + for _, id := range existing { + if !remove[id] { + result = append(result, id) + } + } + return result +} + +func resolveLabelArgs(ctx *common.RuntimeContext, names, idsStr string) ([]int, error) { + if idsStr != "" { + parts := strings.Split(idsStr, ",") + ids := make([]int, 0, len(parts)) + for _, p := range parts { + id, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return nil, fmt.Errorf("invalid label ID %q: %w", p, err) + } + ids = append(ids, id) + } + return ids, nil + } + parts := strings.Split(names, ",") + ids := make([]int, 0, len(parts)) + for _, p := range parts { + name := strings.TrimSpace(p) + if name == "" { + continue + } + id, err := ResolveLabelID(ctx, name) + if err != nil { + return nil, fmt.Errorf("label %q: %w", name, err) + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/shortcuts/issue/batch_open.go b/shortcuts/issue/batch_open.go new file mode 100644 index 0000000..74fd55c --- /dev/null +++ b/shortcuts/issue/batch_open.go @@ -0,0 +1,17 @@ +package issue + +import ( + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const openIssueStatusID = 1 + +func newBatchOpenShortcut(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "batch-open", + Description: tr.T("cmd.issue.batch_open.short"), + Flags: batchStateFlags(tr), + Run: func(ctx *common.RuntimeContext) error { return runBatchStateChange(ctx, "open", openIssueStatusID) }, + } +} diff --git a/shortcuts/issue/batch_operation_test.go b/shortcuts/issue/batch_operation_test.go new file mode 100644 index 0000000..bc57a64 --- /dev/null +++ b/shortcuts/issue/batch_operation_test.go @@ -0,0 +1,377 @@ +package issue + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// RunBatch 核心行为测试 +// --------------------------------------------------------------------------- + +// TestRunBatchDryRunDoesNotCallFn 验证 dry-run 模式下 fn 不被调用, +// 且 summary 正确反映所有 issue 为 succeeded/dry_run。 +func TestRunBatchDryRunDoesNotCallFn(t *testing.T) { + t.Setenv("GITLINK_CONFIRM_BATCH", "") // 隔离环境变量 + + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // dry-run 不应产生任何 HTTP 请求 + t.Fatalf("unexpected HTTP request in dry-run: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + numbers := []string{"1", "2"} + + fn := func(_ *common.RuntimeContext, _ string) error { + t.Fatal("fn should not be called in dry-run mode") + return nil + } + + opts := BatchOptions{DryRun: true, Confirm: false} + summary, err := RunBatch(ctx, numbers, "close", opts, fn) + if err != nil { + t.Fatalf("RunBatch dry-run returned error: %v", err) + } + + common.AssertEqual(t, summary.Total, 2) + common.AssertEqual(t, summary.Succeeded, 2) + common.AssertEqual(t, summary.DryRun, true) + common.AssertEqual(t, summary.Failed, 0) + + for _, r := range summary.Results { + common.AssertEqual(t, r.Status, "dry_run") + } +} + +// TestRunBatchRequiresConfirm 验证非 dry-run 且无 confirm 时返回确认错误。 +func TestRunBatchRequiresConfirm(t *testing.T) { + t.Setenv("GITLINK_CONFIRM_BATCH", "") + + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + + fn := func(_ *common.RuntimeContext, _ string) error { + t.Fatal("fn should not be called without confirm") + return nil + } + + opts := BatchOptions{DryRun: false, Confirm: false} + _, err := RunBatch(ctx, []string{"1"}, "close", opts, fn) + if err == nil { + t.Fatal("expected error when confirm is required but not provided") + } + if !strings.Contains(err.Error(), "confirm") { + t.Fatalf("error should mention 'confirm', got: %v", err) + } +} + +// TestRunBatchWithConfirm 验证 confirm=true 时 fn 被正常调用。 +func TestRunBatchWithConfirm(t *testing.T) { + t.Setenv("GITLINK_CONFIRM_BATCH", "") + + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // fn 不做网络请求,不需要 mock + t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + numbers := []string{"1", "2", "3"} + + var callCount int32 + fn := func(_ *common.RuntimeContext, number string) error { + atomic.AddInt32(&callCount, 1) + return nil + } + + opts := BatchOptions{DryRun: false, Confirm: true} + summary, err := RunBatch(ctx, numbers, "close", opts, fn) + if err != nil { + t.Fatalf("RunBatch with confirm returned error: %v", err) + } + + common.AssertEqual(t, int(atomic.LoadInt32(&callCount)), 3) + common.AssertEqual(t, summary.Total, 3) + common.AssertEqual(t, summary.Succeeded, 3) + common.AssertEqual(t, summary.Failed, 0) +} + +// TestRunBatchMaxTruncation 验证 --max 截断行为。 +func TestRunBatchMaxTruncation(t *testing.T) { + t.Setenv("GITLINK_CONFIRM_BATCH", "") + + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + numbers := []string{"1", "2", "3"} + + fn := func(_ *common.RuntimeContext, _ string) error { + return nil + } + + opts := BatchOptions{DryRun: true, MaxItems: 2} + summary, err := RunBatch(ctx, numbers, "close", opts, fn) + if err == nil { + t.Fatal("expected error when results are truncated") + } + if !strings.Contains(err.Error(), "truncated") { + t.Fatalf("error should mention 'truncated', got: %v", err) + } + + common.AssertEqual(t, summary.Total, 2) // 截断后为 2 + common.AssertEqual(t, summary.Truncated, true) + common.AssertEqual(t, summary.Succeeded, 2) // dry-run 全部 succeeded +} + +// TestRunBatchRecordsFailures 验证 fn 返回错误时记录为 failed。 +func TestRunBatchRecordsFailures(t *testing.T) { + t.Setenv("GITLINK_CONFIRM_BATCH", "") + + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + numbers := []string{"1", "2"} + + var callCount int32 + fn := func(_ *common.RuntimeContext, number string) error { + n := atomic.AddInt32(&callCount, 1) + if n == 2 { + return fmt.Errorf("simulated failure for issue %s", number) + } + return nil + } + + opts := BatchOptions{DryRun: false, Confirm: true} + summary, err := RunBatch(ctx, numbers, "close", opts, fn) + if err == nil { + t.Fatal("expected error when some issues fail") + } + + common.AssertEqual(t, summary.Total, 2) + common.AssertEqual(t, summary.Succeeded, 1) + common.AssertEqual(t, summary.Failed, 1) + common.AssertEqual(t, summary.Results[0].Status, "success") + common.AssertEqual(t, summary.Results[1].Status, "failed") + if summary.Results[1].Error == "" { + t.Fatal("failed result should have an error message") + } +} + +// --------------------------------------------------------------------------- +// patchIssue 测试(httptest mock) +// --------------------------------------------------------------------------- + +// TestPatchIssueMergesExtraFields 验证 patchIssue 将 extraFields 合并到 PATCH body, +// 同时保留 subject 和 description。 +func TestPatchIssueMergesExtraFields(t *testing.T) { + var patchPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + common.WriteJSON(t, w, map[string]interface{}{ + "subject": "Original Title", + "description": "Original Desc", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, patchPayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := newBatchTestCtx(t, server, nil) + err := patchIssue(ctx, "42", map[string]interface{}{"status_id": closeIssueStatusID}, "close") + if err != nil { + t.Fatalf("patchIssue failed: %v", err) + } + + common.AssertEqual(t, patchPayload["subject"], "Original Title") + common.AssertEqual(t, patchPayload["description"], "Original Desc") + common.AssertEqual(t, patchPayload["status_id"], float64(5)) +} + +// --------------------------------------------------------------------------- +// 纯函数单元测试 +// --------------------------------------------------------------------------- + +// TestMergeLabelIDs 验证 mergeLabelIDs 去重合并逻辑。 +func TestMergeLabelIDs(t *testing.T) { + tests := []struct { + name string + a, b []int + want []int + }{ + {"去重合并", []int{1, 2}, []int{2, 3}, []int{1, 2, 3}}, + {"existing 为 nil", nil, []int{1}, []int{1}}, + {"new 为 nil", []int{1}, nil, []int{1}}, + {"两者都为 nil", nil, nil, nil}, + {"完全重复", []int{1, 2}, []int{1, 2}, []int{1, 2}}, + {"existing 为空", []int{}, []int{1, 2}, []int{1, 2}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeLabelIDs(tt.a, tt.b) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("mergeLabelIDs(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// TestRemoveLabelIDs 验证 removeLabelIDs 移除逻辑。 +func TestRemoveLabelIDs(t *testing.T) { + tests := []struct { + name string + existing []int + remove []int + want []int + }{ + {"移除中间元素", []int{1, 2, 3}, []int{2}, []int{1, 3}}, + {"移除不存在的忽略", []int{1, 2}, []int{3}, []int{1, 2}}, + {"全部移除", []int{1, 2}, []int{1, 2}, []int{}}, + {"existing 为空", []int{}, []int{1}, []int{}}, + {"remove 为空", []int{1, 2}, []int{}, []int{1, 2}}, + {"两者都为空", []int{}, []int{}, []int{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := removeLabelIDs(tt.existing, tt.remove) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("removeLabelIDs(%v, %v) = %v, want %v", tt.existing, tt.remove, got, tt.want) + } + }) + } +} + +// TestNormalizeIssueStatus 验证 normalizeIssueStatus 状态映射。 +func TestNormalizeIssueStatus(t *testing.T) { + tests := []struct { + input string + want interface{} + err bool + }{ + {"open", 1, false}, + {"closed", 5, false}, + {"OPEN", 1, false}, + {"Closed", 5, false}, + {"1", 1, false}, // 数字字符串 + {"5", 5, false}, // 数字字符串 + {"invalid", nil, true}, // 无效输入应返回错误 + {"", nil, true}, // 空字符串应返回错误 + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizeIssueStatus(tt.input) + if tt.err { + if err == nil { + t.Fatalf("normalizeIssueStatus(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("normalizeIssueStatus(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("normalizeIssueStatus(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// parseBatchOptions 测试 +// --------------------------------------------------------------------------- + +// TestParseBatchOptions 验证 parseBatchOptions 从 ctx.Args 正确解析各选项。 +func TestParseBatchOptions(t *testing.T) { + t.Run("完整参数解析", func(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "dry-run": "true", + "confirm": "true", + "max": "5", + "delay": "100", + }, + } + opts := parseBatchOptions(ctx) + common.AssertEqual(t, opts.DryRun, true) + common.AssertEqual(t, opts.Confirm, true) + common.AssertEqual(t, opts.MaxItems, 5) + common.AssertEqual(t, opts.DelayMs, 100) + }) + + t.Run("空参数使用默认值", func(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{}, + } + opts := parseBatchOptions(ctx) + common.AssertEqual(t, opts.DryRun, false) + common.AssertEqual(t, opts.Confirm, false) + common.AssertEqual(t, opts.MaxItems, defaultBatchMaxItems) + common.AssertEqual(t, opts.DelayMs, defaultBatchDelayMs) + }) + + t.Run("无效 max 值使用默认值", func(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "max": "not-a-number", + }, + } + opts := parseBatchOptions(ctx) + common.AssertEqual(t, opts.MaxItems, defaultBatchMaxItems) + }) + + t.Run("无效 delay 值使用默认值", func(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "delay": "abc", + }, + } + opts := parseBatchOptions(ctx) + common.AssertEqual(t, opts.DelayMs, defaultBatchDelayMs) + }) +} + +// --------------------------------------------------------------------------- +// 辅助函数 +// --------------------------------------------------------------------------- + +// newBatchTestCtx 构造用于 batch 测试的 RuntimeContext。 +// Args 如果为 nil,则使用空 map。 +func newBatchTestCtx(t *testing.T, server *httptest.Server, args map[string]string) *common.RuntimeContext { + t.Helper() + if args == nil { + args = map[string]string{} + } + return &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } +} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index 42e3648..5cf8bf8 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -1,11 +1,12 @@ package issue import ( - "net/http" "os" "path/filepath" "reflect" "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestParseIssueNumbers(t *testing.T) { @@ -25,51 +26,94 @@ func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) { } } -func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) { +func TestReadCSVWithNumberHeader(t *testing.T) { path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n") - got, err := readIssueNumbersFromCSV(path) + headers, rows, err := ReadCSV(path) if err != nil { - t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + t.Fatalf("ReadCSV returned error: %v", err) + } + col := FindColumn(headers, "number", "issue_number", "project_issues_index") + if col == -1 { + t.Fatal("column 'number' not found") + } + numbers := make([]string, 0, len(rows)) + for _, row := range rows { + numbers = append(numbers, row[col]) + } + numbers, err = normalizeIssueNumbers(numbers) + if err != nil { + t.Fatalf("normalizeIssueNumbers returned error: %v", err) } want := []string{"12", "13"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + if !reflect.DeepEqual(numbers, want) { + t.Fatalf("got %#v, want %#v", numbers, want) } } -func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) { +func TestReadCSVWithProjectIssuesIndexHeader(t *testing.T) { path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n") - got, err := readIssueNumbersFromCSV(path) + headers, rows, err := ReadCSV(path) if err != nil { - t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + t.Fatalf("ReadCSV returned error: %v", err) + } + col := FindColumn(headers, "number", "issue_number", "project_issues_index") + if col == -1 { + t.Fatal("column 'project_issues_index' not found") + } + numbers := make([]string, 0, len(rows)) + for _, row := range rows { + numbers = append(numbers, row[col]) + } + numbers, err = normalizeIssueNumbers(numbers) + if err != nil { + t.Fatalf("normalizeIssueNumbers returned error: %v", err) } want := []string{"12", "13"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + if !reflect.DeepEqual(numbers, want) { + t.Fatalf("got %#v, want %#v", numbers, want) } } -func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) { +func TestReadCSVHeaderlessReturnsNoColumnMatch(t *testing.T) { path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n") - got, err := readIssueNumbersFromCSV(path) + headers, rows, err := ReadCSV(path) if err != nil { - t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + t.Fatalf("ReadCSV returned error: %v", err) } - want := []string{"21", "22"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + // 无表头时 FindColumn 返回 -1 + col := FindColumn(headers, "number", "issue_number", "project_issues_index") + if col != -1 { + t.Fatalf("expected -1 for headerless CSV, got %d", col) + } + // 退回到首列(index 0)作为 issue 编号来源 + col = 0 + numbers := make([]string, 0, len(rows)) + for _, row := range rows { + numbers = append(numbers, row[col]) + } + numbers, err = normalizeIssueNumbers(numbers) + if err != nil { + t.Fatalf("normalizeIssueNumbers returned error: %v", err) + } + want := []string{"22", "21"} + if !reflect.DeepEqual(numbers, want) { + t.Fatalf("got %#v, want %#v", numbers, want) } } -func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) { +func TestResolveIssueNumbersMergesCLIAndCSV(t *testing.T) { path := writeTempCSV(t, "number\n2\n3\n") - got, err := collectIssueNumbers("1,2", path) + ctx := &common.RuntimeContext{ + Owner: "owner", + Repo: "repo", + } + got, err := ResolveIssueNumbers(ctx, "1,2", path, "") if err != nil { - t.Fatalf("collectIssueNumbers returned error: %v", err) + t.Fatalf("ResolveIssueNumbers returned error: %v", err) } want := []string{"1", "2", "3"} if !reflect.DeepEqual(got, want) { - t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want) + t.Fatalf("ResolveIssueNumbers() = %#v, want %#v", got, want) } } @@ -214,136 +258,3 @@ func writeTempCSV(t *testing.T, content string) string { } return path } - -func TestBatchUpdateDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "ids": "101,102", - "status-id": "3", - "priority-id": "2", - "tag-ids": "7,8", - "assigner-ids": "11", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-update dry-run failed: %v", err) - } -} - -func TestBatchUpdateCallsAPI(t *testing.T) { - var payload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "ids": "101,102,101", - "status-id": "3", - "priority-id": "2", - "milestone-id": "9", - "tag-ids": "7,8", - "assigner-ids": "11,12", - }) - if err != nil { - t.Fatalf("batch-update failed: %v", err) - } - assertFloatSlice(t, payload["ids"], []float64{101, 102}) - assertEqual(t, payload["status_id"], float64(3)) - assertEqual(t, payload["priority_id"], float64(2)) - assertEqual(t, payload["milestone_id"], float64(9)) - assertFloatSlice(t, payload["issue_tag_ids"], []float64{7, 8}) - assertFloatSlice(t, payload["assigner_ids"], []float64{11, 12}) -} - -func TestBatchUpdateRequiresUpdateField(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-update", map[string]string{"ids": "101"}); err == nil { - t.Fatal("expected error when no update fields are provided") - } -} - -func TestBatchUpdateRejectsInvalidIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []map[string]string{ - {"ids": "abc", "status-id": "3"}, - {"ids": "101", "status-id": "bad"}, - {"ids": "101", "tag-ids": "7,,8"}, - } - for _, args := range cases { - if err := runShortcut(t, server, "batch-update", args); err == nil { - t.Fatalf("expected validation error for args %#v", args) - } - } -} - -func TestBatchDeleteDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102", "dry-run": "true"}); err != nil { - t.Fatalf("batch-delete dry-run failed: %v", err) - } -} - -func TestBatchDeleteRequiresYes(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call without --yes: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101"}); err == nil { - t.Fatal("expected --yes confirmation error") - } -} - -func TestBatchDeleteCallsAPIWithYes(t *testing.T) { - var payload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/batch_destroy.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102,101", "yes": "true"}); err != nil { - t.Fatalf("batch-delete failed: %v", err) - } - assertFloatSlice(t, payload["ids"], []float64{101, 102}) -} - -func assertFloatSlice(t *testing.T, got interface{}, want []float64) { - t.Helper() - items, ok := got.([]interface{}) - if !ok { - t.Fatalf("got %#v, want []interface{}", got) - } - if len(items) != len(want) { - t.Fatalf("got len %d, want %d: %#v", len(items), len(want), got) - } - for i := range want { - if items[i] != want[i] { - t.Fatalf("item %d = %#v, want %#v", i, items[i], want[i]) - } - } -} diff --git a/shortcuts/issue/batch_update.go b/shortcuts/issue/batch_update.go new file mode 100644 index 0000000..bb45a1e --- /dev/null +++ b/shortcuts/issue/batch_update.go @@ -0,0 +1,229 @@ +package issue + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var updateFieldMapping = map[string]string{ + "title": "subject", + "body": "description", + "state": "status_id", + "assignee": "assigner_ids", + "milestone": "milestone_id", + "label": "issue_tag_ids", + "priority": "priority_id", +} + +func newBatchUpdateShortcut(tr *i18n.Translator) *common.Shortcut { + flags := []common.Flag{ + // --ids 统一模式参数 + {Name: "ids", Short: "i", Usage: tr.T("flag.issue.batch_update.ids"), Required: false}, + {Name: "status", Short: "s", Usage: tr.T("flag.issue.batch_update.status")}, + {Name: "priority", Short: "p", Usage: tr.T("flag.issue.batch_update.priority")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.batch_update.milestone")}, + {Name: "labels", Short: "l", Usage: tr.T("flag.issue.batch_update.tags")}, + {Name: "assignees", Short: "a", Usage: tr.T("flag.issue.batch_update.assignees")}, + // CSV 模式参数 + {Name: "from", Usage: tr.T("flag.issue.batch_update.csv")}, + } + flags = append(flags, batchRuntimeFlags(tr)...) + return &common.Shortcut{ + Name: "batch-update", + Description: tr.T("cmd.issue.batch_update.short"), + Flags: flags, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + if csvPath := ctx.Arg("from"); csvPath != "" { + return runBatchUpdateCSV(ctx, csvPath) + } + return runBatchUpdateIDs(ctx) +} + +func runBatchUpdateCSV(ctx *common.RuntimeContext, csvPath string) error { + headers, rows, err := ReadCSV(csvPath) + if err != nil { + return err + } + + numberCol := FindColumn(headers, "number", "issue_number", "project_issues_index") + if numberCol == -1 { + return fmt.Errorf("CSV 缺少编号列(number/issue_number/project_issues_index)") + } + + numbers := make([]string, 0, len(rows)) + rowByNumber := make(map[string][]string) + for _, row := range rows { + if numberCol < len(row) { + n := strings.TrimSpace(row[numberCol]) + if n != "" { + if _, exists := rowByNumber[n]; !exists { + numbers = append(numbers, n) + } else { + fmt.Fprintf(os.Stderr, "警告:issue #%s 在 CSV 中出现多次,仅使用最后一次的数据\n", n) + } + rowByNumber[n] = row + } + } + } + + opts := parseBatchOptions(ctx) + + updateFn := func(c *common.RuntimeContext, number string) error { + row, ok := rowByNumber[number] + if !ok { + return fmt.Errorf("no CSV data for issue #%s", number) + } + return applyIssueUpdates(c, number, row, headers) + } + _, err = RunBatch(ctx, numbers, "update", opts, updateFn) + return err +} + +func runBatchUpdateIDs(ctx *common.RuntimeContext) error { + idsValue, err := ctx.RequireArg("ids") + if err != nil { + return err + } + ids, err := parseCommaInts(idsValue) + if err != nil { + return err + } + + body := map[string]interface{}{ + "ids": ids, + } + + if s := ctx.Arg("status"); s != "" { + statusID, err := normalizeIssueStatus(s) + if err != nil { + return err + } + body["status_id"] = statusID + } + if p := ctx.Arg("priority"); p != "" { + pid, err := strconv.Atoi(p) + if err != nil { + return fmt.Errorf("无效的优先级 ID: %s", p) + } + body["priority_id"] = pid + } + if m := ctx.Arg("milestone"); m != "" { + mid, err := strconv.Atoi(m) + if err != nil { + return fmt.Errorf("无效的里程碑 ID: %s", m) + } + body["milestone_id"] = mid + } + if l := ctx.Arg("labels"); l != "" { + labelIDs, err := parseCommaInts(l) + if err != nil { + return fmt.Errorf("无效的标签 ID: %w", err) + } + body["issue_tag_ids"] = labelIDs + } + if a := ctx.Arg("assignees"); a != "" { + assigneeIDs, err := parseCommaInts(a) + if err != nil { + return fmt.Errorf("无效的负责人 ID: %w", err) + } + body["assigner_ids"] = assigneeIDs + } + + dryRun := parseBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-update", + "dry_run": true, + "ids": ids, + "changes": body, + }) + } + + env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func applyIssueUpdates(ctx *common.RuntimeContext, number string, row []string, headers []string) error { + current, err := fetchIssueData(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + + for i, colName := range headers { + colName = strings.ToLower(strings.TrimSpace(colName)) + apiField, ok := updateFieldMapping[colName] + if !ok || i >= len(row) { + continue + } + val := strings.TrimSpace(row[i]) + if val == "" { + continue + } + + switch apiField { + case "subject": + body["subject"] = val + case "description": + body["description"] = val + case "status_id": + sid, err := normalizeIssueStatus(val) + if err != nil { + return fmt.Errorf("issue #%s state %q: %w", number, val, err) + } + body["status_id"] = sid + case "assigner_ids": + id, err := ResolveUserID(ctx, val) + if err != nil { + return fmt.Errorf("issue #%s assignee %q: %w", number, val, err) + } + body["assigner_ids"] = []int{id} + case "milestone_id": + if id, err := strconv.Atoi(val); err == nil { + body["milestone_id"] = id + } else { + id, err := ResolveMilestoneID(ctx, val) + if err != nil { + return fmt.Errorf("issue #%s milestone %q: %w", number, val, err) + } + body["milestone_id"] = id + } + case "issue_tag_ids": + labelIDs, err := resolveLabelArgs(ctx, val, "") + if err != nil { + return fmt.Errorf("issue #%s label %q: %w", number, val, err) + } + body["issue_tag_ids"] = labelIDs + case "priority_id": + pid, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("issue #%s priority %q: must be a numeric priority_id", number, val) + } + body["priority_id"] = pid + } + } + _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return fmt.Errorf("update issue: %w", err) + } + return nil +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..55d9a7a 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -11,11 +11,30 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo} +// v1RepoPath 返回 v1 API 路径前缀:/v1/{owner}/{repo}。 +// issue 相关端点都走 v1 前缀,与其它资源(如 label、pr)的 /v0 路径不同。 func v1RepoPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } +// IssueData 记录从 issue 接口读出的全部字段。 +// batch 流程使用 Subject、Description、LabelIDs;close/update 命令使用全部字段 +// 来构造保留现有 metadata 的 PATCH body。 +// StatusID/PriorityID 为 interface{} 以兼容 API 返回的嵌套对象 id(如 status.id)。 +type IssueData struct { + Subject string + Description string + StatusID interface{} + AssignedToID int + FixedVersionID int + PriorityID interface{} + LabelIDs []int + AssignerIDs []interface{} + BranchName string + StartDate string + DueDate string +} + func normalizeIssueListState(state string) string { switch strings.ToLower(strings.TrimSpace(state)) { case "open", "opened": @@ -29,24 +48,16 @@ func normalizeIssueListState(state string) string { } } -type existingIssue struct { - Subject string - Description string - StatusID interface{} - PriorityID interface{} - TagIDs []interface{} - AssignerIDs []interface{} - BranchName string - StartDate string - DueDate string -} - func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ - newBatchCloseShortcut(), - newBatchUpdateShortcut(), - newBatchDeleteShortcut(), + newBatchCreateShortcut(tr), + newBatchCloseShortcut(tr), + newBatchOpenShortcut(tr), + newBatchAssignShortcut(tr), + newBatchLabelShortcut(tr), + newBatchUpdateShortcut(tr), + newBatchDeleteShortcut(tr), { Name: "list", Description: tr.T("cmd.issue.list.short"), @@ -189,7 +200,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - current, err := fetchExistingIssue(ctx, number) + current, err := fetchIssueData(ctx, number) if err != nil { return err } @@ -236,7 +247,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return fmt.Errorf("at least one update field is required") } - current, err := fetchExistingIssue(ctx, number) + current, err := fetchIssueData(ctx, number) if err != nil { return err } @@ -475,42 +486,116 @@ func normalizeIssueListIDs(env *output.Envelope) { } } -func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { +// fetchIssueData 从 API 读取指定 issue 的完整数据。 +// JSON 反序列化得到的 float64 / []interface{} 会被规范化为 int / []int。 +func fetchIssueData(ctx *common.RuntimeContext, number string) (*IssueData, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { return nil, err } - issueData, ok := getEnv.Data.(map[string]interface{}) + issueMap, ok := getEnv.Data.(map[string]interface{}) if !ok { return nil, fmt.Errorf("failed to parse issue data") } - subject, _ := issueData["subject"].(string) + subject, _ := issueMap["subject"].(string) if subject == "" { return nil, fmt.Errorf("failed to parse issue subject") } - description, _ := issueData["description"].(string) - return &existingIssue{ - Subject: subject, - Description: description, - StatusID: nestedIssueID(issueData, "status"), - PriorityID: nestedIssueID(issueData, "priority"), - TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"), - AssignerIDs: issueObjectIDs(issueData, "assigners"), - BranchName: stringField(issueData, "branch_name"), - StartDate: stringField(issueData, "start_date"), - DueDate: stringField(issueData, "due_date"), - }, nil + + data := &IssueData{ + Subject: subject, + Description: getMapString(issueMap, "description"), + StatusID: nestedIssueID(issueMap, "status"), + AssignedToID: getMapInt(issueMap, "assigned_to_id"), + FixedVersionID: getNestedMapInt(issueMap, "milestone", "id"), + PriorityID: nestedIssueID(issueMap, "priority"), + LabelIDs: getTagIDs(issueMap, "tags"), + AssignerIDs: issueObjectIDs(issueMap, "assigners"), + BranchName: getMapString(issueMap, "branch_name"), + StartDate: getMapString(issueMap, "start_date"), + DueDate: getMapString(issueMap, "due_date"), + } + return data, nil } -func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { +// getMapString 从 map 中安全提取 string 值,类型不匹配时返回空串。 +func getMapString(m map[string]interface{}, key string) string { + s, _ := m[key].(string) + return s +} + +// getMapInt 从 map 中提取 int 值,兼容 JSON 反序列化得到的 float64。 +// 类型不匹配或缺失时返回 0。 +func getMapInt(m map[string]interface{}, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + } + return 0 +} + +// getNestedMapInt 从 map 的嵌套对象字段中提取 int 类型的值。 +// 例如 issueMap["milestone"] 是 {id: 2764, name: "v1.0"}, +// getNestedMapInt(issueMap, "milestone", "id") 返回 2764。 +// 字段缺失或类型不匹配时返回 0。 +func getNestedMapInt(m map[string]interface{}, outerKey, innerKey string) int { + outer, ok := m[outerKey].(map[string]interface{}) + if !ok { + return 0 + } + return getMapInt(outer, innerKey) +} + +// getMapIntSlice 从 map 中提取 []int,元素类型兼容 float64(JSON 数字)。 +// 类型不匹配或缺失时返回 nil。 +func getMapIntSlice(m map[string]interface{}, key string) []int { + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + ids := make([]int, 0, len(raw)) + for _, item := range raw { + switch v := item.(type) { + case float64: + ids = append(ids, int(v)) + case int: + ids = append(ids, v) + } + } + return ids +} + +// getTagIDs 从 map 中提取 tag 对象数组中每个对象的 id 字段。 +// API 返回 tags: [{id: 1, name: "bug"}, ...],需要遍历对象提取 id。 +// 类型不匹配或缺失时返回 nil。 +func getTagIDs(m map[string]interface{}, key string) []int { + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + ids := make([]int, 0, len(raw)) + for _, item := range raw { + if tag, ok := item.(map[string]interface{}); ok { + id := getMapInt(tag, "id") + if id > 0 { + ids = append(ids, id) + } + } + } + return ids +} + +func preserveIssueMetadata(body map[string]interface{}, issue *IssueData) { if issue.StatusID != nil { body["status_id"] = issue.StatusID } if issue.PriorityID != nil { body["priority_id"] = issue.PriorityID } - if len(issue.TagIDs) > 0 { - body["issue_tag_ids"] = issue.TagIDs + if len(issue.LabelIDs) > 0 { + body["issue_tag_ids"] = issue.LabelIDs } if len(issue.AssignerIDs) > 0 { body["assigner_ids"] = issue.AssignerIDs @@ -572,7 +657,7 @@ func normalizeIssueStatus(state string) (interface{}, error) { if id, err := strconv.Atoi(state); err == nil { return id, nil } - return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) + return nil, fmt.Errorf("无效的 --state %q:请使用 open、closed 或数字 status_id", state) } } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..48ebdec 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -777,12 +777,20 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ - "numbers": "42", - "dry-run": "false", - }) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{}, + } + + err := patchIssue(ctx, "42", map[string]interface{}{"status_id": closeIssueStatusID}, "close") if err != nil { - t.Fatalf("batch-close shortcut failed: %v", err) + t.Fatalf("patchIssue (close) failed: %v", err) } assertEqual(t, updatePayload["subject"], "Existing title") assertEqual(t, updatePayload["description"], "Existing description") @@ -1058,72 +1066,3 @@ func TestIssueCloseHTTPError(t *testing.T) { t.Fatal("expected error for PATCH HTTP 500") } } - -func TestFetchExistingIssueBadData(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, "not a map") - }) - defer server.Close() - - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - } - _, err := fetchExistingIssue(ctx, "1") - if err == nil { - t.Fatal("expected error for non-map response") - } -} - -func TestFetchExistingIssueNoSubject(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{"id": float64(1)}) - }) - defer server.Close() - - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - } - _, err := fetchExistingIssue(ctx, "1") - if err == nil { - t.Fatal("expected error for missing subject") - } -} - -// --- normalizeIssueStatus --- - -func TestNormalizeIssueStatus(t *testing.T) { - tests := []struct { - input string - want interface{} - wantErr bool - }{ - {"open", 1, false}, - {"OPEN", 1, false}, - {" open ", 1, false}, - {"closed", 5, false}, - {"CLOSED", 5, false}, - {"0", 0, false}, - {"10", 10, false}, - {"invalid", nil, true}, - {"", nil, true}, - } - for _, tt := range tests { - got, err := normalizeIssueStatus(tt.input) - if tt.wantErr { - if err == nil { - t.Errorf("normalizeIssueStatus(%q) expected error", tt.input) - } - } else { - if err != nil { - t.Errorf("normalizeIssueStatus(%q) error: %v", tt.input, err) - } - if got != tt.want { - t.Errorf("normalizeIssueStatus(%q) = %v, want %v", tt.input, got, tt.want) - } - } - } -} diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 2b6a298..ba27143 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -1,48 +1,42 @@ package label import ( - "encoding/json" "fmt" "net/url" - "regexp" - "strconv" - "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// defaultLabelColor is used when the caller does not provide a color. -const defaultLabelColor = "#1E90FF" - -// hexColorPattern matches #RGB and #RRGGBB hex color values. -var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) - -// Shortcuts returns issue label (项目标记) management shortcuts. -// -// Issue labels back the issue triage and PR gatekeeping workflows: until now -// they could only be managed through the raw API (issue_tags), so these -// shortcuts close that gap with first-class create/list/update/delete commands. +// Shortcuts returns all shortcuts for label management. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: "List issue labels", + Description: "List issue labels (tags)", Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Filter labels by keyword"}, - {Name: "only-name", Usage: "Return only label id and name: true or false"}, - {Name: "sort-by", Usage: "Sort field: updated_on, created_on, issues_count"}, - {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "order-by", Usage: "Sort field: updated_on, created_on, issues_count", Default: "created_on"}, + {Name: "order-direction", Usage: "Sort direction: asc, desc", Default: "desc"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } q := url.Values{} - setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) - setQueryIfPresent(q, "order_by", ctx.Arg("sort-by")) - setQueryIfPresent(q, "order_direction", ctx.Arg("sort-direction")) - env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if k := ctx.Arg("keyword"); k != "" { + q.Set("keyword", k) + } + if o := ctx.Arg("order-by"); o != "" { + q.Set("order_by", o) + } + if d := ctx.Arg("order-direction"); d != "" { + q.Set("order_direction", d) + } + env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/issue_tags", q) if err != nil { return err } @@ -51,28 +45,77 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create an issue label", + Description: "Create an issue label (tag)", Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Label name", Required: true}, + {Name: "color", Short: "c", Usage: "Color hex (e.g. #FF0000)"}, {Name: "description", Short: "d", Usage: "Label description"}, - {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF", Default: defaultLabelColor}, }, - Run: runCreate, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + body := map[string]interface{}{ + "name": name, + } + if c := ctx.Arg("color"); c != "" { + body["color"] = c + } + if d := ctx.Arg("description"); d != "" { + body["description"] = d + } + env, err := ctx.CallAPI("POST", v1Path(ctx)+"/issue_tags", body) + if err != nil { + return err + } + return ctx.Output(env) + }, }, { Name: "update", - Description: "Update an issue label while preserving unspecified fields", + Description: "Update an issue label (tag)", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Label ID", Required: true}, - {Name: "name", Short: "n", Usage: "Label name"}, - {Name: "description", Short: "d", Usage: "Label description"}, - {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF"}, + {Name: "name", Short: "n", Usage: "New label name"}, + {Name: "color", Short: "c", Usage: "New color hex (e.g. #FF0000)"}, + {Name: "description", Short: "d", Usage: "New description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + payload := map[string]interface{}{} + if n := ctx.Arg("name"); n != "" { + payload["name"] = n + } + if c := ctx.Arg("color"); c != "" { + payload["color"] = c + } + if d := ctx.Arg("description"); d != "" { + payload["description"] = d + } + if len(payload) == 0 { + return fmt.Errorf("至少需要指定 --name, --color 或 --description 之一") + } + env, err := ctx.CallAPI("PATCH", + fmt.Sprintf("%s/issue_tags/%s", v1Path(ctx), id), payload) + if err != nil { + return err + } + return ctx.Output(env) }, - Run: runUpdate, }, { Name: "delete", - Description: "Delete an issue label", + Description: "Delete an issue label (tag)", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Label ID", Required: true}, }, @@ -84,7 +127,7 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("DELETE", labelItemPath(ctx, id), nil) + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1Path(ctx), id), nil) if err != nil { return err } @@ -94,151 +137,6 @@ func Shortcuts() []*common.Shortcut { } } -func runCreate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - color := firstNonEmpty(ctx.Arg("color"), defaultLabelColor) - if err := validateColor(color); err != nil { - return err - } - payload := map[string]interface{}{ - "name": name, - "description": ctx.Arg("description"), - "color": color, - } - env, err := ctx.CallAPI("POST", labelPath(ctx), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - if ctx.Arg("name") == "" && ctx.Arg("description") == "" && ctx.Arg("color") == "" { - return fmt.Errorf("at least one of --name, --description, or --color is required") - } - - // The update endpoint requires name, description and color together, so we - // merge the requested changes onto the label's current values to avoid - // clobbering fields the caller did not pass. - current, err := fetchLabel(ctx, id) - if err != nil { - return err - } - - name := firstNonEmpty(ctx.Arg("name"), stringFromMap(current, "name")) - if name == "" { - return fmt.Errorf("could not resolve label name for id %s; pass --name explicitly", id) - } - color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor) - if err := validateColor(color); err != nil { - return err - } - description := ctx.Arg("description") - if description == "" { - description = stringFromMap(current, "description") - } - - payload := map[string]interface{}{ - "name": name, - "description": description, - "color": color, - } - env, err := ctx.CallAPI("PATCH", labelItemPath(ctx, id), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -// fetchLabel looks up a single label by id from the list endpoint. GitLink does -// not expose a single-label GET, so we page through the list and match by id. -// A nil result (label not found) is not an error: the caller falls back to the -// flags it was given. -func fetchLabel(ctx *common.RuntimeContext, id 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 labelIDString(tag["id"]) == id { - return tag, nil - } - } - return nil, nil -} - -func labelPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) -} - -func labelItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) -} - -func validateColor(color string) error { - if !hexColorPattern.MatchString(color) { - return fmt.Errorf("invalid --color value %q: use a hex color like #1E90FF or #abc", color) - } - return nil -} - -func labelIDString(v interface{}) string { - switch id := v.(type) { - case string: - return id - case float64: - return strconv.FormatInt(int64(id), 10) - case json.Number: - return id.String() - default: - return "" - } -} - -func setQueryIfPresent(q url.Values, name, value string) { - if value != "" { - q.Set(name, value) - } -} - -func stringFromMap(values map[string]interface{}, key string) string { - if values == nil { - return "" - } - value, _ := values[key].(string) - return value -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" +func v1Path(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go index 66bd5dc..bb2964b 100644 --- a/shortcuts/label/label_test.go +++ b/shortcuts/label/label_test.go @@ -1,237 +1,133 @@ package label 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 TestLabelList(t *testing.T) { - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/issue_tags.json") - if got := r.URL.Query().Get("keyword"); got != "bug" { - t.Fatalf("got keyword %q, want %q", got, "bug") - } - if got := r.URL.Query().Get("order_by"); got != "issues_count" { - t.Fatalf("got order_by %q, want %q", got, "issues_count") - } - writeJSON(t, w, map[string]interface{}{"total_count": 0, "issue_tags": []interface{}{}}) - }) - defer server.Close() - - err := runLabelShortcut(t, server, "list", map[string]string{ - "keyword": "bug", - "sort-by": "issues_count", - }) - if err != nil { - t.Fatalf("list shortcut failed: %v", err) - } -} - -func TestLabelCreatePayload(t *testing.T) { - var payload map[string]interface{} - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - err := runLabelShortcut(t, server, "create", map[string]string{ - "name": "bug", - "description": "Something is broken", - "color": "#FF0000", - }) - if err != nil { - t.Fatalf("create shortcut failed: %v", err) - } - - assertEqual(t, payload["name"], "bug") - assertEqual(t, payload["description"], "Something is broken") - assertEqual(t, payload["color"], "#FF0000") -} - -func TestLabelCreateUsesDefaultColor(t *testing.T) { - var payload map[string]interface{} - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runLabelShortcut(t, server, "create", map[string]string{"name": "enhancement"}); err != nil { - t.Fatalf("create shortcut failed: %v", err) - } - assertEqual(t, payload["color"], defaultLabelColor) -} - -func TestLabelCreateRejectsInvalidColor(t *testing.T) { - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid color should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runLabelShortcut(t, server, "create", map[string]string{ - "name": "bug", - "color": "red", - }) - if err == nil { - t.Fatal("expected invalid color to return an error") - } -} - -func TestLabelUpdatePreservesCurrentFields(t *testing.T) { - var payload map[string]interface{} - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json": - writeJSON(t, w, map[string]interface{}{ - "total_count": 1, + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json" { + common.WriteJSON(t, w, map[string]interface{}{ "issue_tags": []interface{}{ map[string]interface{}{ - "id": float64(7), - "name": "bug", - "description": "old description", - "color": "#FF0000", + "name": "bug", + "color": "#FF0000", }, }, + "total_count": 1, }) - case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/7.json": - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - default: + } else { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } }) defer server.Close() - err := runLabelShortcut(t, server, "update", map[string]string{ - "id": "7", - "color": "#00FF00", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "order-by": "created_on", + "order-direction": "desc", }) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { - t.Fatalf("update shortcut failed: %v", err) + t.Fatalf("list failed: %v", err) } - - // name and description preserved from current; only color changed. - assertEqual(t, payload["name"], "bug") - assertEqual(t, payload["description"], "old description") - assertEqual(t, payload["color"], "#00FF00") } -func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) { - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("update with no fields should not call API, got: %s %s", r.Method, r.URL.Path) +func TestLabelCreate(t *testing.T) { + var createPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" { + createPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "创建成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() - err := runLabelShortcut(t, server, "update", map[string]string{"id": "7"}) - if err == nil { - t.Fatal("expected update with no fields to return an error") + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "enhancement", + "color": "#00FF00", + "description": "New feature", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err != nil { + t.Fatalf("create failed: %v", err) } + + common.AssertEqual(t, createPayload["name"], "enhancement") + common.AssertEqual(t, createPayload["color"], "#00FF00") } func TestLabelDelete(t *testing.T) { - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/v1/owner/repo/issue_tags/7.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "删除成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() - if err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { - t.Fatalf("delete shortcut failed: %v", err) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "3", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) } } -func TestValidateColor(t *testing.T) { - valid := []string{"#1E90FF", "#abc", "#ABCDEF", "#000"} - for _, c := range valid { - if err := validateColor(c); err != nil { - t.Fatalf("expected %q to be valid, got %v", c, err) - } - } - invalid := []string{"red", "1E90FF", "#12", "#GGGGGG", "#1234", ""} - for _, c := range invalid { - if err := validateColor(c); err == nil { - t.Fatalf("expected %q to be invalid", c) +func TestLabelUpdate(t *testing.T) { + var updatePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/7.json" { + updatePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "更新成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "7", + "name": "enhancement", + "color": "#0000FF", + "description": "New feature", + }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err != nil { + t.Fatalf("update failed: %v", err) } + + common.AssertEqual(t, updatePayload["name"], "enhancement") + common.AssertEqual(t, updatePayload["color"], "#0000FF") + common.AssertEqual(t, updatePayload["description"], "New feature") } -func TestLabelIDString(t *testing.T) { - assertEqual(t, labelIDString(float64(7)), "7") - assertEqual(t, labelIDString("9"), "9") - assertEqual(t, labelIDString(json.Number("11")), "11") - assertEqual(t, labelIDString(nil), "") -} +func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without update fields") + }) + defer server.Close() -func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findLabelShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findLabelShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func newLabelTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) - } -} - -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err == nil { + t.Fatal("expected error when no fields provided, got nil") } } diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 3734da3..6b54d29 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -1,36 +1,20 @@ package member import ( - "encoding/csv" - "fmt" - "net/url" - "os" - "strconv" - "strings" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -var roleAliases = map[string]string{ - "manager": "Manager", - "developer": "Developer", - "reporter": "Reporter", - "Manager": "Manager", - "Developer": "Developer", - "Reporter": "Reporter", -} - -// Shortcuts returns repository member management shortcuts. +// Shortcuts returns all shortcuts for project member management. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: "List repository members", + Description: "List project members", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/collaborators", nil) if err != nil { return err } @@ -39,154 +23,46 @@ func Shortcuts() []*common.Shortcut { }, { Name: "add", - Description: "Add a repository member by user ID", + Description: "Add a project member", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, + {Name: "user-id", Short: "u", Usage: "User ID to add", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - userID, err := parseUserID(ctx.Arg("user-id")) + userID, err := ctx.RequireArg("user-id") if err != nil { return err } - env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + body := map[string]interface{}{ + "user_id": userID, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body) if err != nil { return err } return ctx.Output(env) }, }, - { - 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", - Description: "Remove a repository member by user ID", + Description: "Remove a project member", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, + {Name: "user-id", Short: "u", Usage: "User ID to remove", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - userID, err := parseUserID(ctx.Arg("user-id")) + userID, err := ctx.RequireArg("user-id") if err != nil { return err } - env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID}) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "role", - Description: "Change a repository member role", - Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, - {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - userID, err := parseUserID(ctx.Arg("user-id")) - if err != nil { - return err - } - role, err := normalizeRole(ctx.Arg("role")) - if err != nil { - return err - } - env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{ + body := map[string]interface{}{ "user_id": userID, - "role": role, - }) - if err != nil { - return err } - return ctx.Output(env) - }, - }, - { - 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"}, - {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - role, err := normalizeInviteRole(ctx.Arg("role")) - if err != nil { - return err - } - apply, err := parseBoolArg("apply", ctx.Arg("apply")) - if err != nil { - return err - } - query := url.Values{} - query.Set("role", role) - query.Set("is_apply", strconv.FormatBool(apply)) - env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "invite-info", - Description: "Show repository invite link information", - Flags: []common.Flag{ - {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sign, err := ctx.RequireArg("sign") - if err != nil { - return err - } - query := url.Values{} - query.Set("invite_sign", sign) - env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "accept-invite", - Description: "Accept a repository invite link", - Flags: []common.Flag{ - {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sign, err := ctx.RequireArg("sign") - if err != nil { - return err - } - query := url.Values{} - query.Set("invite_sign", sign) - env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query) + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body) if err != nil { return err } @@ -195,198 +71,3 @@ func Shortcuts() []*common.Shortcut { }, } } - -func runBatchAdd(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) - if err != nil { - return err - } - if len(userIDs) == 0 { - return fmt.Errorf("provide --user-ids or --from") - } - if parseDryRun(ctx.Arg("dry-run")) { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "user_ids": userIDs, - "count": len(userIDs), - }) - } - - results := make([]map[string]interface{}, 0, len(userIDs)) - succeeded := 0 - failed := 0 - for _, userID := range userIDs { - env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) - result := map[string]interface{}{"user_id": userID} - if err != nil { - result["ok"] = false - result["error"] = err.Error() - failed++ - } else { - result["ok"] = env.OK - result["data"] = env.Data - if env.OK { - succeeded++ - } else { - failed++ - } - } - results = append(results, result) - } - if err := ctx.OutputData(map[string]interface{}{ - "count": len(userIDs), - "succeeded": succeeded, - "failed": failed, - "results": results, - }); err != nil { - return err - } - if failed > 0 { - return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs)) - } - return nil -} - -func collaboratorsPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) -} - -func collaboratorsRemovePath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) -} - -func collaboratorsRolePath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx)) -} - -func inviteLinkPath(ctx *common.RuntimeContext, action string) string { - return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) -} - -func parseUserID(value string) (int, error) { - value = strings.TrimSpace(value) - userID, err := strconv.Atoi(value) - if err != nil || userID <= 0 { - return 0, fmt.Errorf("invalid user ID %q", value) - } - return userID, nil -} - -func normalizeRole(value string) (string, error) { - 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 { - return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) - } - return strings.ToLower(role), nil -} - -func parseBoolArg(name, value string) (bool, error) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "", "true": - return true, nil - case "false": - return false, nil - default: - return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) - } -} - -func parseDryRun(value string) bool { - ok, _ := parseBoolArg("dry-run", value) - return ok && strings.TrimSpace(value) != "" -} - -func collectUserIDs(inline, csvPath string) ([]int, error) { - seen := map[int]bool{} - var ids []int - add := func(raw string) error { - if strings.TrimSpace(raw) == "" { - return nil - } - userID, err := parseUserID(raw) - if err != nil { - return err - } - if !seen[userID] { - seen[userID] = true - ids = append(ids, userID) - } - return nil - } - - for _, part := range strings.Split(inline, ",") { - if err := add(part); err != nil { - return nil, err - } - } - if csvPath != "" { - csvIDs, err := readUserIDsFromCSV(csvPath) - if err != nil { - return nil, err - } - for _, userID := range csvIDs { - if !seen[userID] { - seen[userID] = true - ids = append(ids, userID) - } - } - } - return ids, nil -} - -func readUserIDsFromCSV(path string) ([]int, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - rows, err := csv.NewReader(file).ReadAll() - if err != nil { - return nil, err - } - if len(rows) == 0 { - return nil, nil - } - - column := 0 - start := 0 - if idx := userIDColumn(rows[0]); idx >= 0 { - column = idx - start = 1 - } - - var ids []int - for _, row := range rows[start:] { - if column >= len(row) { - continue - } - userID, err := parseUserID(row[column]) - if err != nil { - return nil, err - } - ids = append(ids, userID) - } - return ids, nil -} - -func userIDColumn(header []string) int { - for i, name := range header { - switch strings.ToLower(strings.TrimSpace(name)) { - case "user_id", "userid", "id": - return i - } - } - return -1 -} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index d379e82..a06f5dc 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -1,286 +1,85 @@ package member import ( - "encoding/json" "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" "testing" - "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestMemberList(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/collaborators.json") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}}) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/collaborators.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "login": "developer", + "role": "Manager", + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() - if err := runMemberShortcut(t, server, "list", nil); err != nil { - t.Fatalf("list shortcut failed: %v", err) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) + if err != nil { + t.Fatalf("list failed: %v", err) } } func TestMemberAdd(t *testing.T) { - var payload map[string]interface{} - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/collaborators.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil { - t.Fatalf("add shortcut failed: %v", err) - } - assertNumber(t, payload["user_id"], 101) -} - -func TestMemberBatchAdd(t *testing.T) { - var seen []int - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/collaborators.json") - payload := decodeJSON(t, r) - seen = append(seen, int(payload["user_id"].(float64))) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - csvPath := writeTempCSV(t, "user_id\n102\n103\n") - err := runMemberShortcut(t, server, "batch-add", map[string]string{ - "user-ids": "101,102", - "from": csvPath, - }) - if err != nil { - t.Fatalf("batch-add shortcut failed: %v", err) - } - want := []int{101, 102, 103} - if !reflect.DeepEqual(seen, want) { - t.Fatalf("batch-add user IDs = %v, want %v", seen, want) - } -} - -func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runMemberShortcut(t, server, "batch-add", map[string]string{ - "user-ids": "101,102", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-add dry-run failed: %v", err) - } -} - -func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/collaborators.json") - payload := decodeJSON(t, r) - if int(payload["user_id"].(float64)) == 102 { - http.Error(w, "member add failed", http.StatusBadRequest) - return + var addPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" { + addPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "添加成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) }) defer server.Close() - err := runMemberShortcut(t, server, "batch-add", map[string]string{ - "user-ids": "101,102", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "user-id": "42", }) - if err == nil { - t.Fatal("expected batch-add to return an error when one request fails") + err := common.RunShortcut(t, Shortcuts(), "add", ctx) + if err != nil { + t.Fatalf("add failed: %v", err) } + + common.AssertEqual(t, addPayload["user_id"], "42") } func TestMemberRemove(t *testing.T) { - var payload map[string]interface{} - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + var removePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" { + removePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "删除成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() - if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil { - t.Fatalf("remove shortcut failed: %v", err) - } - assertNumber(t, payload["user_id"], 101) -} - -func TestMemberRole(t *testing.T) { - var payload map[string]interface{} - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - err := runMemberShortcut(t, server, "role", map[string]string{ - "user-id": "101", - "role": "developer", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "user-id": "42", }) + err := common.RunShortcut(t, Shortcuts(), "remove", ctx) if err != nil { - t.Fatalf("role shortcut failed: %v", err) - } - assertNumber(t, payload["user_id"], 101) - if payload["role"] != "Developer" { - t.Fatalf("role = %v, want Developer", payload["role"]) + t.Fatalf("remove failed: %v", err) } -} - -func TestMemberInviteLink(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json") - if r.URL.Query().Get("role") != "developer" { - t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role")) - } - if r.URL.Query().Get("is_apply") != "false" { - t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply")) - } - writeJSON(t, w, map[string]interface{}{"sign": "abc"}) - }) - defer server.Close() - - err := runMemberShortcut(t, server, "invite-link", map[string]string{ - "role": "developer", - "apply": "false", - }) - if err != nil { - t.Fatalf("invite-link shortcut failed: %v", err) - } -} - -func TestMemberInviteInfo(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json") - if r.URL.Query().Get("invite_sign") != "abc" { - t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) - } - writeJSON(t, w, map[string]interface{}{"sign": "abc"}) - }) - defer server.Close() - - if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil { - t.Fatalf("invite-info shortcut failed: %v", err) - } -} - -func TestMemberAcceptInvite(t *testing.T) { - server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json") - if r.URL.Query().Get("invite_sign") != "abc" { - t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) - } - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil { - t.Fatalf("accept-invite shortcut failed: %v", err) - } -} - -func TestCollectUserIDs(t *testing.T) { - csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n") - got, err := collectUserIDs("101,102", csvPath) - if err != nil { - t.Fatalf("collectUserIDs returned error: %v", err) - } - want := []int{101, 102, 103} - if !reflect.DeepEqual(got, want) { - t.Fatalf("collectUserIDs() = %v, want %v", got, want) - } -} - -func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { - if _, err := normalizeRole("owner"); err == nil { - t.Fatal("expected invalid role to return an error") - } -} - -func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findMemberShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findMemberShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) - } -} - -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertNumber(t *testing.T, got interface{}, want int) { - t.Helper() - value, ok := got.(float64) - if !ok { - t.Fatalf("got %v (%T), want JSON number", got, got) - } - if int(value) != want { - t.Fatalf("got %v, want %d", got, want) - } -} - -func writeTempCSV(t *testing.T, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "members.csv") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write temp csv: %v", err) - } - return path + + common.AssertEqual(t, removePayload["user_id"], "42") } diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index 1ec2b30..f3b1d45 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -3,11 +3,11 @@ package milestone import ( "fmt" "net/url" - "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// Shortcuts returns all shortcuts for milestone management. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { @@ -15,10 +15,7 @@ func Shortcuts() []*common.Shortcut { Description: "List milestones", Flags: []common.Flag{ {Name: "keyword", Short: "k", Usage: "Search keyword"}, - {Name: "category", Short: "c", Usage: "Filter by category: opening, closed"}, - {Name: "only-name", Usage: "Return only milestone id and name: true or false"}, - {Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"}, - {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "status", Short: "s", Usage: "Filter by status: open, closed, all", Default: "all"}, {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, @@ -29,12 +26,13 @@ func Shortcuts() []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setQueryIfPresent(q, "category", ctx.Arg("category")) - setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) - setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) - setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) - env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) + if k := ctx.Arg("keyword"); k != "" { + q.Set("keyword", k) + } + if s := ctx.Arg("status"); s != "all" { + q.Set("status", s) + } + env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/milestones", q) if err != nil { return err } @@ -46,18 +44,27 @@ func Shortcuts() []*common.Shortcut { Description: "Create a milestone", Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, - {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, - {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - payload, err := milestonePayload(ctx, true) + name, err := ctx.RequireArg("name") if err != nil { return err } - env, err := ctx.CallAPI("POST", milestonePath(ctx), payload) + body := map[string]interface{}{ + "name": name, + } + if desc := ctx.Arg("description"); desc != "" { + body["description"] = desc + } + if due := ctx.Arg("due-date"); due != "" { + body["effective_date"] = due + } + env, err := ctx.CallAPI("POST", v1Path(ctx)+"/milestones", body) if err != nil { return err } @@ -66,17 +73,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View milestone details and linked issues", + Description: "View milestone details with associated issues", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, - {Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"}, - {Name: "author-id", Usage: "Filter issues by author ID"}, - {Name: "assigner-id", Usage: "Filter issues by assignee ID"}, - {Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"}, - {Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"}, - {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "category", Short: "c", Usage: "Issue filter: all, opened, closed", Default: "all"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -87,15 +87,10 @@ func Shortcuts() []*common.Shortcut { return err } q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - setQueryIfPresent(q, "category", ctx.Arg("category")) - setQueryIfPresent(q, "author_id", ctx.Arg("author-id")) - setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id")) - setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids"))) - setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) - setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) - env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q) + if c := ctx.Arg("category"); c != "all" { + q.Set("category", c) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/milestones/%s", v1Path(ctx), id), q) if err != nil { return err } @@ -103,13 +98,10 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "update", - Description: "Update a milestone", + Name: "close", + Description: "Close a milestone", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, - {Name: "name", Short: "n", Usage: "Milestone name"}, - {Name: "description", Short: "d", Usage: "Milestone description"}, - {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,11 +111,10 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - payload, err := milestonePayload(ctx, false) - if err != nil { - return err + body := map[string]interface{}{ + "status": "closed", } - env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload) + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/milestones/%s/update_status", v1Path(ctx), id), body) if err != nil { return err } @@ -144,96 +135,16 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil) + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1Path(ctx), id), nil) if err != nil { return err } return ctx.Output(env) }, }, - newStatusShortcut("close", "Close a milestone", "closed"), - newStatusShortcut("reopen", "Reopen a milestone", "open"), } } -func milestonePath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo) -} - -func milestoneItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id)) -} - -func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) -} - -func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { - payload := map[string]interface{}{} - if name := ctx.Arg("name"); name != "" { - payload["name"] = name - } - if description := ctx.Arg("description"); description != "" { - payload["description"] = description - } - if dueDate := ctx.Arg("due-date"); dueDate != "" { - payload["effective_date"] = dueDate - } - - if requireAll { - for _, name := range []string{"name", "description", "due-date"} { - if _, err := ctx.RequireArg(name); err != nil { - return nil, err - } - } - return payload, nil - } - if len(payload) == 0 { - return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required") - } - return payload, nil -} - -func newStatusShortcut(name, description, status string) *common.Shortcut { - return &common.Shortcut{ - Name: name, - Description: description, - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{ - "status": status, - }) - if err != nil { - return err - } - return ctx.Output(env) - }, - } -} - -func setQueryIfPresent(q url.Values, name, value string) { - if value != "" { - q.Set(name, value) - } -} - -func normalizeCSV(value string) string { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return strings.Join(result, ",") +func v1Path(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go index bf76193..f697230 100644 --- a/shortcuts/milestone/milestone_test.go +++ b/shortcuts/milestone/milestone_test.go @@ -1,207 +1,144 @@ package milestone 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 TestMilestoneList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json") - assertEqual(t, r.URL.Query().Get("category"), "opening") - assertEqual(t, r.URL.Query().Get("keyword"), "v1") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}}) - })) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/milestones.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "milestones": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "name": "v1.0", + "status": "open", + }, + }, + "total_count": 1, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runMilestoneShortcut(t, server, "list", map[string]string{ - "category": "opening", - "keyword": "v1", - "page": "2", - "limit": "50", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "status": "all", + "page": "1", + "limit": "20", }) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { - t.Fatalf("list shortcut failed: %v", err) + t.Fatalf("list failed: %v", err) } } -func TestMilestoneCreatePayload(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) +func TestMilestoneCreate(t *testing.T) { + var createPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones.json" { + createPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(2), + "name": "v2.0", + "status": "open", + "message": "创建成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runMilestoneShortcut(t, server, "create", map[string]string{ - "name": "v1.0", - "description": "first release", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "v2.0", + "description": "Second release", "due-date": "2026-07-01", }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) if err != nil { - t.Fatalf("create shortcut failed: %v", err) + t.Fatalf("create failed: %v", err) } - assertEqual(t, payload["name"], "v1.0") - assertEqual(t, payload["description"], "first release") - assertEqual(t, payload["effective_date"], "2026-07-01") + common.AssertEqual(t, createPayload["name"], "v2.0") + common.AssertEqual(t, createPayload["description"], "Second release") + common.AssertEqual(t, createPayload["effective_date"], "2026-07-01") } -func TestMilestoneViewWithIssueFilters(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json") - assertEqual(t, r.URL.Query().Get("category"), "opened") - assertEqual(t, r.URL.Query().Get("author_id"), "11") - assertEqual(t, r.URL.Query().Get("assigner_id"), "22") - assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3") - writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}}) - })) - defer server.Close() - - err := runMilestoneShortcut(t, server, "view", map[string]string{ - "id": "7", - "category": "opened", - "author-id": "11", - "assigner-id": "22", - "issue-tag-ids": "1, 2,3", +func TestMilestoneView(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/milestones/1.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "name": "v1.0", + "status": "open", + "issues": []interface{}{}, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) - if err != nil { - t.Fatalf("view shortcut failed: %v", err) - } -} - -func TestMilestoneUpdatePayload(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) defer server.Close() - err := runMilestoneShortcut(t, server, "update", map[string]string{ - "id": "7", - "due-date": "2026-08-01", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + "category": "all", }) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) if err != nil { - t.Fatalf("update shortcut failed: %v", err) - } - - if _, ok := payload["name"]; ok { - t.Fatal("update payload should omit empty name") - } - assertEqual(t, payload["effective_date"], "2026-08-01") -} - -func TestMilestoneUpdateRequiresChange(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"}) - if err == nil { - t.Fatal("expected update without fields to return an error") + t.Fatalf("view failed: %v", err) } } func TestMilestoneDelete(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } -} - -func TestMilestoneCloseAndReopen(t *testing.T) { - gotStatuses := []string{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json") - payload := decodeJSON(t, r) - gotStatuses = append(gotStatuses, payload["status"].(string)) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil { - t.Fatalf("close shortcut failed: %v", err) - } - if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil { - t.Fatalf("reopen shortcut failed: %v", err) - } - assertEqual(t, gotStatuses[0], "closed") - assertEqual(t, gotStatuses[1], "open") -} - -func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findMilestoneShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findMilestoneShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/milestones/1.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "删除成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - } - t.Fatalf("shortcut %q not found", name) - return nil -} + }) + defer server.Close() -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) } } -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} +func TestMilestoneClose(t *testing.T) { + var closePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Regression guard: +close must hit /v1/{owner}/{repo}/milestones/{id}/update_status + // (previously malformed to /{owner}/{repo}/{owner}/milestones/{id}/update_status — Owner duplicated, Repo dropped, no /v1). + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones/1/update_status.json" { + closePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "更新成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "close", ctx) + if err != nil { + t.Fatalf("close failed: %v", err) } + common.AssertEqual(t, closePayload["status"], "closed") } diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index f0b5e72..4e45561 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -74,7 +74,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Run: func(ctx *common.RuntimeContext) error { name, _ := ctx.RequireArg("name") payload := map[string]interface{}{ - "name": name, + "name": name, + "nickname": name, + "visibility": "common", } if d := ctx.Arg("description"); d != "" { payload["description"] = d @@ -86,6 +88,115 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "teams", + Description: "List teams in an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id") + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", id), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create-team", + Description: "Create a team in an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.description")}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id") + name, _ := ctx.RequireArg("name") + payload := map[string]interface{}{ + "name": name, + "nickname": name, + } + if d := ctx.Arg("description"); d != "" { + payload["description"] = d + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "remove-member", + Description: "Remove a member from an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "uid", Short: "u", Usage: "User ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id") + uid, _ := ctx.RequireArg("uid") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "nickname", + Description: "Set or view a member's nickname in an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "uid", Short: "u", Usage: "User ID", Required: true}, + {Name: "nickname", Short: "n", Usage: "New nickname (omit to view current)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id") + uid, _ := ctx.RequireArg("uid") + nickname := ctx.Arg("nickname") + if nickname != "" { + payload := map[string]interface{}{ + "nickname": nickname, + } + env, err := ctx.CallAPI("PUT", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), payload) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "uid", + Description: "Look up a user's numeric ID by login name", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/org/org_test.go b/shortcuts/org/org_test.go index 067e4a2..d501099 100644 --- a/shortcuts/org/org_test.go +++ b/shortcuts/org/org_test.go @@ -1,182 +1,276 @@ package org 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 runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(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 writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- list --- - func TestOrgList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/organizations.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/organizations.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "organizations": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "name": "test-org", + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, []interface{}{ - map[string]interface{}{"login": "org1"}, - map[string]interface{}{"login": "org2"}, - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { t.Fatalf("list failed: %v", err) } } -// --- info --- - func TestOrgInfo(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/organizations/myorg.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/organizations/5.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(5), + "name": "test-org", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{"login": "myorg", "name": "My Org"}) - })) + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"id": "myorg"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + }) + err := common.RunShortcut(t, Shortcuts(), "info", ctx) if err != nil { t.Fatalf("info failed: %v", err) } } -// --- members --- - func TestOrgMembers(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/organizations/myorg/organization_users.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/organizations/5/organization_users.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(2), + "organization_users": []interface{}{ + map[string]interface{}{ + "user": map[string]interface{}{"login": "alice"}, + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, []interface{}{ - map[string]interface{}{"login": "user1"}, - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "members", map[string]string{"id": "myorg", "page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + }) + err := common.RunShortcut(t, Shortcuts(), "members", ctx) if err != nil { t.Fatalf("members failed: %v", err) } } -// --- create --- - func TestOrgCreate(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/organizations.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + payload := common.DecodeJSON(t, r) + if payload["name"] != "new-org" { + t.Fatalf("expected name=new-org, got %v", payload["name"]) } - if r.Method != "POST" { - t.Fatalf("expected POST, got %s", r.Method) + if payload["nickname"] != "new-org" { + t.Fatalf("expected nickname=new-org, got %v", payload["nickname"]) } - writeJSON(w, map[string]interface{}{"login": "neworg"}) - })) + if payload["visibility"] != "common" { + t.Fatalf("expected visibility=common, got %v", payload["visibility"]) + } + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(10), + "name": "new-org", + }) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"name": "neworg", "description": "A new org"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "name": "new-org", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) if err != nil { t.Fatalf("create failed: %v", err) } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) + } } -func TestOrgCreateNoDescription(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"login": "neworg"}) - })) +// --- teams --- + +func TestOrgTeams(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/organizations/5/teams.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "teams": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "name": "dev-team", + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"name": "neworg"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + }) + err := common.RunShortcut(t, Shortcuts(), "teams", ctx) if err != nil { - t.Fatalf("create failed: %v", err) + t.Fatalf("teams failed: %v", err) } } -// --- HTTP error paths --- +// --- create-team --- -func TestOrgListHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +func TestOrgCreateTeam(t *testing.T) { + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + payload := common.DecodeJSON(t, r) + if payload["name"] != "new-team" { + t.Fatalf("expected name=new-team, got %v", payload["name"]) + } + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "name": "new-team", + }) + }) defer server.Close() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + "name": "new-team", + }) + err := common.RunShortcut(t, Shortcuts(), "create-team", ctx) + if err != nil { + t.Fatalf("create-team failed: %v", err) + } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) } } -func TestOrgInfoHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +// --- remove-member --- + +func TestOrgRemoveMember(t *testing.T) { + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "ok": true, + }) + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"id": "myorg"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + "uid": "42", + }) + err := common.RunShortcut(t, Shortcuts(), "remove-member", ctx) + if err != nil { + t.Fatalf("remove-member failed: %v", err) + } + if requestMethod != "DELETE" { + t.Errorf("expected DELETE, got %s", requestMethod) } } -func TestOrgMembersHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +// --- nickname (view) --- + +func TestOrgNicknameView(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/organizations/5/organization_users/42.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "nickname": "thename", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runShortcut(t, server, "members", map[string]string{"id": "myorg", "page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + "uid": "42", + }) + err := common.RunShortcut(t, Shortcuts(), "nickname", ctx) + if err != nil { + t.Fatalf("nickname failed: %v", err) } } -func TestOrgCreateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +// --- nickname (set) --- + +func TestOrgNicknameSet(t *testing.T) { + var requestMethod string + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + payload := common.DecodeJSON(t, r) + if payload["nickname"] != "newname" { + t.Fatalf("expected nickname=newname, got %v", payload["nickname"]) + } + common.WriteJSON(t, w, map[string]interface{}{ + "nickname": "newname", + }) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"name": "neworg"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "id": "5", + "uid": "42", + "nickname": "newname", + }) + err := common.RunShortcut(t, Shortcuts(), "nickname", ctx) + if err != nil { + t.Fatalf("nickname set failed: %v", err) + } + if requestMethod != "PUT" { + t.Errorf("expected PUT, got %s", requestMethod) + } +} + +// --- uid --- + +func TestOrgUID(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/baoerjun.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(148287), + "login": "baoerjun", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "login": "baoerjun", + }) + err := common.RunShortcut(t, Shortcuts(), "uid", ctx) + if err != nil { + t.Fatalf("uid failed: %v", err) } } diff --git a/shortcuts/pm/pm.go b/shortcuts/pm/pm.go new file mode 100644 index 0000000..0f42e1d --- /dev/null +++ b/shortcuts/pm/pm.go @@ -0,0 +1,122 @@ +package pm + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "boards", + Description: "List kanban boards", + 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 { + return listPM(ctx, "/pm/dashboards") + }, + }, + { + Name: "sprints", + Description: "List sprint issues", + 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 { + return listPM(ctx, "/pm/sprint_issues") + }, + }, + { + Name: "weekly", + Description: "List weekly reports", + 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 { + return listPM(ctx, "/pm/weekly_issues") + }, + }, + { + Name: "tags", + Description: "List PM issue tags", + 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 { + return listPM(ctx, "/pm/issue_tags") + }, + }, + { + Name: "pipelines", + Description: "List PM pipelines", + 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 { + return listPM(ctx, "/pm/pipelines") + }, + }, + { + Name: "actions", + Description: "List action run records", + 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 { + return listPM(ctx, "/pm/action_runs") + }, + }, + } +} + +func listPM(ctx *common.RuntimeContext, endpoint string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", strconv.Itoa(projectID)) + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIRawWithQuery("GET", endpoint, q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func fetchProjectID(ctx *common.RuntimeContext) (int, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("获取项目信息失败: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("无法解析项目信息") + } + if idFloat, ok := data["repo_id"].(float64); ok { + return int(idFloat), nil + } + if idFloat, ok := data["project_id"].(float64); ok { + return int(idFloat), nil + } + if idFloat, ok := data["id"].(float64); ok { + return int(idFloat), nil + } + return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在") +} diff --git a/shortcuts/pm/pm_test.go b/shortcuts/pm/pm_test.go new file mode 100644 index 0000000..18b498d --- /dev/null +++ b/shortcuts/pm/pm_test.go @@ -0,0 +1,200 @@ +package pm + +import ( + "net/http" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFetchProjectID(t *testing.T) { + cases := []struct { + name string + response map[string]interface{} + wantID int + }{ + {"repo_id", map[string]interface{}{"repo_id": float64(100)}, 100}, + {"project_id", map[string]interface{}{"project_id": float64(200)}, 200}, + {"id", map[string]interface{}{"id": float64(300)}, 300}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := tc.response + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo.json" { + common.WriteJSON(t, w, resp) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + id, err := fetchProjectID(ctx) + if err != nil { + t.Fatalf("fetchProjectID failed: %v", err) + } + if id != tc.wantID { + t.Fatalf("got %d, want %d", id, tc.wantID) + } + }) + } +} + +func TestFetchProjectIDNotFound(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + common.WriteJSON(t, w, map[string]interface{}{"name": "repo"}) + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + _, err := fetchProjectID(ctx) + if err == nil { + t.Fatal("expected error for missing project ID") + } +} + +func TestPMBoards(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/dashboards": + common.WriteJSON(t, w, map[string]interface{}{ + "boards": []interface{}{ + map[string]interface{}{"id": 1, "name": "Sprint 1"}, + map[string]interface{}{"id": 2, "name": "Sprint 2"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "boards", ctx) + if err != nil { + t.Fatalf("boards failed: %v", err) + } +} + +func TestPMSprints(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/sprint_issues": + common.WriteJSON(t, w, map[string]interface{}{ + "issues": []interface{}{ + map[string]interface{}{"id": 10, "subject": "Task A"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "sprints", ctx) + if err != nil { + t.Fatalf("sprints failed: %v", err) + } +} + +func TestPMWeekly(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/weekly_issues": + common.WriteJSON(t, w, map[string]interface{}{ + "reports": []interface{}{ + map[string]interface{}{"id": 1, "title": "Week 21"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "weekly", ctx) + if err != nil { + t.Fatalf("weekly failed: %v", err) + } +} + +func TestPMTags(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/issue_tags": + common.WriteJSON(t, w, map[string]interface{}{ + "tags": []interface{}{ + map[string]interface{}{"id": 1, "name": "bug"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "tags", ctx) + if err != nil { + t.Fatalf("tags failed: %v", err) + } +} + +func TestPMPipelines(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/pipelines": + common.WriteJSON(t, w, map[string]interface{}{ + "pipelines": []interface{}{ + map[string]interface{}{"id": 1, "name": "CI"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "pipelines", ctx) + if err != nil { + t.Fatalf("pipelines failed: %v", err) + } +} + +func TestPMActions(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)}) + case r.Method == "GET" && r.URL.Path == "/pm/action_runs": + common.WriteJSON(t, w, map[string]interface{}{ + "runs": []interface{}{ + map[string]interface{}{"id": 1, "status": "success"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "actions", ctx) + if err != nil { + t.Fatalf("actions failed: %v", err) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..65e833d 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -104,14 +104,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } title, _ := ctx.RequireArg("title") head, _ := ctx.RequireArg("head") - base := ctx.Arg("base") - if base == "" { - base = "master" - } payload := map[string]interface{}{ "title": title, "head": head, - "base": base, + "base": ctx.Arg("base"), } if b := ctx.Arg("body"); b != "" { payload["body"] = b @@ -231,17 +227,29 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "diff", Description: tr.T("cmd.pr.diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "file", Short: "f", Usage: "Filter diff to a specific file path"}, + {Name: "stat", Usage: "Show only diff stat summary", Bool: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + + // v0 API:返回变更文件列表及 patch 内容。 + q := url.Values{} + if f := ctx.Arg("file"); f != "" { + q.Set("filepath", f) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), q) if err != nil { return err } + + if ctx.Arg("stat") == "true" { + return ctx.Output(formatDiffStat(env)) + } return ctx.Output(env) }, }, @@ -337,9 +345,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.pr.review.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content")}, + {Name: "body", Short: "b", Usage: tr.T("flag.pr.review_content")}, {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"}, - {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true}, - {Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")}, + {Name: "commit-id", Usage: tr.T("flag.pr.review_commit")}, {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -350,9 +359,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - content, err := ctx.RequireArg("content") - if err != nil { - return err + // --content 和 --body 互为别名,至少传一个。 + content := ctx.Arg("content") + if content == "" { + content, err = ctx.RequireArg("body") + if err != nil { + return err + } } status := ctx.Arg("status") if status == "" { @@ -365,7 +378,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "content": content, "status": status, } - if commit := ctx.Arg("commit"); commit != "" { + if commit := ctx.Arg("commit-id"); commit != "" { payload["commit_id"] = commit } if ctx.Arg("dry-run") == "true" { @@ -382,19 +395,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return err } - // Also post a journal comment so the review is visible in the PR conversation. - prEnv, journalErr := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) - if journalErr == nil { - if issueID, extractErr := extractIssueID(prEnv); extractErr == nil { - statusLabel := map[string]string{ - "approved": "approved", "rejected": "rejected", "common": "commented", - }[status] - summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content) - ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), - map[string]interface{}{"notes": summary}) - } - } - return ctx.Output(env) }, }, @@ -414,7 +414,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) if err != nil { - return fmt.Errorf("fetch PR: %w", err) + return fmt.Errorf("获取 PR 详情失败: %w", err) } issueID, err := extractIssueID(prEnv) if err != nil { @@ -431,27 +431,108 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "check-merge", + Description: "Check if branches can be merged", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch", Required: true}, + {Name: "base", Short: "b", Usage: "Target branch", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, _ := ctx.RequireArg("head") + base, _ := ctx.RequireArg("base") + payload := map[string]interface{}{ + "head": head, + "base": base, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "branches", + Description: "List available branches for PR", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] +// getLatestVersionID calls the versions API and returns the latest version's ID. +// GitLink returns versions in reverse chronological order, so the first is latest. +func getLatestVersionID(ctx *common.RuntimeContext, prID string) (string, error) { + env, err := ctx.CallAPI("GET", + fmt.Sprintf("/v1%s/pulls/%s/versions", ctx.RepoPath(), prID), nil) + if err != nil { + return "", err } - return i18n.Default() + + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected versions response format") + } + + versions, ok := data["versions"].([]interface{}) + if !ok || len(versions) == 0 { + return "", fmt.Errorf("PR #%s 没有找到版本信息", prID) + } + + latest, ok := versions[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected version format") + } + + idFloat, ok := latest["id"].(float64) + if !ok { + return "", fmt.Errorf("version missing id field") + } + + return fmt.Sprintf("%d", int64(idFloat)), nil } -func prV1Path(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) -} - -func validatePRReviewStatus(status string) error { - switch status { - case "common", "approved", "rejected": - return nil - default: - return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status) +// formatDiffStat extracts add/delete statistics from the diff response. +func formatDiffStat(env *output.Envelope) *output.Envelope { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return env } + + stat := map[string]interface{}{ + "file_nums": data["file_nums"], + "total_addition": data["total_addition"], + "total_deletion": data["total_deletion"], + } + + if files, ok := data["files"].([]interface{}); ok { + var fileStats []map[string]interface{} + for _, f := range files { + if fm, ok := f.(map[string]interface{}); ok { + fileStats = append(fileStats, map[string]interface{}{ + "name": fm["name"], + "addition": fm["addition"], + "deletion": fm["deletion"], + "type": fm["type"], + }) + } + } + stat["files"] = fileStats + } + + return output.SuccessEnvelope(stat, nil) } func extractIssueID(env *output.Envelope) (int64, error) { @@ -559,3 +640,25 @@ func numberField(m map[string]interface{}, key string) (float64, bool) { return 0, false } } + +// prV1Path returns the v1 API path for a specific PR. +func prV1Path(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) +} + +// validatePRReviewStatus validates the review status value. +func validatePRReviewStatus(status string) error { + switch strings.ToLower(strings.TrimSpace(status)) { + case "common", "approved", "rejected", "": + return nil + default: + return fmt.Errorf("invalid review status %q: use common, approved, or rejected", status) + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..b50a505 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -2,9 +2,9 @@ package pr import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -18,7 +18,7 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/13.json": - writeJSON(t, w, map[string]interface{}{ + common.WriteJSON(t, w, map[string]interface{}{ "issue": map[string]interface{}{ "id": float64(142301), "subject": "test PR", @@ -29,8 +29,8 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { }) case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/142301/journals.json": journalPath = r.URL.Path - journalPayload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{ + journalPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ "id": float64(12345), "message": "评论成功", }) @@ -51,13 +51,13 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { if journalPath == "" { t.Fatal("journal endpoint was not called") } - assertEqual(t, journalPayload["notes"], "LGTM, looks good!") + common.AssertEqual(t, journalPayload["notes"], "LGTM, looks good!") } func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - writeJSON(t, w, map[string]interface{}{ + common.WriteJSON(t, w, map[string]interface{}{ "status": 404, "error": "Not Found", }) @@ -75,7 +75,7 @@ func TestPRCommentFailsWhenPRNotFound(t *testing.T) { func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ + common.WriteJSON(t, w, map[string]interface{}{ "pull_request": map[string]interface{}{ "id": float64(14791), }, @@ -508,6 +508,182 @@ func findPRShortcut(t *testing.T, name string) *common.Shortcut { return nil } +// --- Review tests (from master) --- + +func TestPRReviewsList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/reviews.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": 1, + "reviews": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "content": "LGTM", + "status": "approved", + }, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runPRShortcut(t, server, "reviews", map[string]string{ + "id": "13", + }) + if err != nil { + t.Fatalf("reviews list failed: %v", err) + } +} + +func TestPRReviewCreate(t *testing.T) { + var reviewPayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/pulls/13/reviews.json" { + reviewPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(2), + "content": "Looks good", + "status": "approved", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runPRShortcut(t, server, "review", map[string]string{ + "id": "13", + "body": "Looks good", + "status": "approved", + }) + if err != nil { + t.Fatalf("review create failed: %v", err) + } + + common.AssertEqual(t, reviewPayload["content"], "Looks good") + common.AssertEqual(t, reviewPayload["status"], "approved") +} + +// --- Diff tests --- + +func TestPRDiffWithFileFilter(t *testing.T) { + var requestPath string + var requestQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestQuery = r.URL.RawQuery + common.WriteJSON(t, w, []interface{}{ + map[string]interface{}{"filename": "src/main.go", "patch": "@@ -1 +1 @@"}, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "diff", map[string]string{ + "id": "42", + "file": "src/main.go", + }) + if err != nil { + t.Fatalf("diff with file filter failed: %v", err) + } + + if requestPath != "/owner/repo/pulls/42/files.json" { + t.Fatalf("unexpected path: %s", requestPath) + } + if !strings.Contains(requestQuery, "filepath=src") { + t.Errorf("expected filepath query param, got: %s", requestQuery) + } +} + +func TestPRCheckMergePostsCorrectPayload(t *testing.T) { + var requestMethod string + var requestPath string + var checkPayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + requestPath = r.URL.Path + if r.Body != nil { + checkPayload = common.DecodeJSON(t, r) + } + common.WriteJSON(t, w, map[string]interface{}{ + "can_merge": true, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feature-branch", + "base": "master", + }) + if err != nil { + t.Fatalf("check-merge shortcut failed: %v", err) + } + + common.AssertEqual(t, requestMethod, "POST") + common.AssertEqual(t, requestPath, "/owner/repo/pulls/check_can_merge.json") + common.AssertEqual(t, checkPayload["head"], "feature-branch") + common.AssertEqual(t, checkPayload["base"], "master") +} + +func TestPRBranchesList(t *testing.T) { + var requestMethod string + var requestPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + requestPath = r.URL.Path + common.WriteJSON(t, w, map[string]interface{}{ + "branches": []interface{}{ + "master", + "develop", + }, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "branches", map[string]string{}) + if err != nil { + t.Fatalf("branches shortcut failed: %v", err) + } + + common.AssertEqual(t, requestMethod, "GET") + common.AssertEqual(t, requestPath, "/owner/repo/pulls/get_branches.json") +} + +func TestPRDiffFailsWhenPRNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(404), + "error": "Not Found", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "diff", map[string]string{ + "id": "999", + }) + if err == nil { + t.Fatal("expected error for non-existent PR, got nil") + } +} + +// writeJSON is a thin local alias used by the upstream-merged PR tests; it +// delegates to common.WriteJSON to avoid a second copy of the implementation. +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + common.WriteJSON(t, w, payload) +} + +// assertEqual is a thin local alias used by the upstream-merged PR tests; it +// delegates to common.AssertEqual. +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + common.AssertEqual(t, got, want) +} + +// decodeJSON decodes an HTTP request body into a map; used by the +// upstream-merged PR tests that assert on request payloads. func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { t.Helper() var payload map[string]interface{} @@ -516,18 +692,3 @@ func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { } return payload } - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) - } -} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..b9e0b2a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,6 +9,8 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" + "github.com/gitlink-org/gitlink-cli/shortcuts/explore" + "github.com/gitlink-org/gitlink-cli/shortcuts/file" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" @@ -18,11 +20,13 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "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/profile" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" + "github.com/gitlink-org/gitlink-cli/shortcuts/snippet" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -38,50 +42,58 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(tr), "issue": issue.Shortcuts(tr), - "label": label.Shortcuts(), - "license": license.Shortcuts(), - "member": member.Shortcuts(), - "milestone": milestone.Shortcuts(), - "pipeline": pipeline.Shortcuts(), "pr": pr.Shortcuts(tr), - "profile": profile.Shortcuts(tr), "release": release.Shortcuts(tr), "branch": branch.Shortcuts(tr), "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), + "milestone": milestone.Shortcuts(), + "label": label.Shortcuts(), + "file": file.Shortcuts(), + "webhook": webhook.Shortcuts(tr), + "member": member.Shortcuts(), + "snippet": snippet.Shortcuts(), + "wiki": wiki.Shortcuts(), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), + "explore": explore.Shortcuts(tr), "health": health.Shortcuts(tr), "ignore": ignore.Shortcuts(), + "license": license.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pm": pm.Shortcuts(), + "profile": profile.Shortcuts(tr), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ "repo": tr.T("cmd.repo.short"), "issue": tr.T("cmd.issue.short"), - "label": "Issue label operations", - "license": "License operations", - "member": "Repository member operations", - "milestone": "Milestone operations", - "pipeline": "Pipeline operations", "pr": tr.T("cmd.pr.short"), - "profile": tr.T("cmd.profile.short"), "release": tr.T("cmd.release.short"), "branch": tr.T("cmd.branch.short"), "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), + "milestone": "Milestone operations", + "label": "Issue label (tag) operations", + "file": "File operations", + "webhook": tr.T("cmd.webhook.short"), + "member": "Project member operations", + "snippet": "Local code snippet management", + "wiki": "Wiki operations", "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", + "explore": "Explore pinned projects and categories", "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), + "ignore": "Gitignore template operations", + "license": "License operations", + "pipeline": "Pipeline operations", + "pm": "Project management operations", + "profile": tr.T("cmd.profile.short"), "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..b2f9b8d 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", + "dataset", "health", "ignore", "file", "snippet", "pm", "wiki", } groupSet := map[string]bool{} diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 21d7ec4..5f719cf 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -2,12 +2,15 @@ package release import ( "fmt" + "io" + "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -183,16 +186,101 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { _, viewErr := ctx.CallAPI("GET", path, nil) if viewErr != nil { // Release no longer exists — delete actually succeeded - return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + return ctx.OutputData(map[string]interface{}{ "message": "删除成功", - }, nil)) + }) } // Release still exists — delete truly failed return delErr } - return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + return ctx.OutputData(map[string]interface{}{ "message": "删除成功", - }, nil)) + }) + }, + }, + { + Name: "download", + Description: "Download release assets", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Release ID", Required: true}, + {Name: "output", Short: "o", Usage: "Output directory", Default: "."}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + outputDir := ctx.Arg("output") + + // Fetch release details to find assets + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected release response format") + } + + assets, _ := data["assets"].([]interface{}) + if len(assets) == 0 { + return ctx.OutputData(map[string]interface{}{ + "message": "没有可下载的资源", + }) + } + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("创建输出目录失败: %w", err) + } + + var downloaded []string + for _, a := range assets { + asset, _ := a.(map[string]interface{}) + downloadURL, _ := asset["url"].(string) + filename, _ := asset["filename"].(string) + if downloadURL == "" || filename == "" { + continue + } + + // Build full URL if relative + if downloadURL[0] == '/' { + downloadURL = ctx.Client.BaseURL + downloadURL + } + + resp, err := ctx.Client.HTTP.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载 %s 失败: %w", filename, err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return fmt.Errorf("下载 %s 失败: HTTP %d", filename, resp.StatusCode) + } + + destPath := filepath.Join(outputDir, filename) + f, err := os.Create(destPath) + if err != nil { + resp.Body.Close() + return fmt.Errorf("创建文件 %s 失败: %w", destPath, err) + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + resp.Body.Close() + return fmt.Errorf("写入文件 %s 失败: %w", destPath, err) + } + f.Close() + resp.Body.Close() + downloaded = append(downloaded, filename) + } + + return ctx.OutputData(map[string]interface{}{ + "message": fmt.Sprintf("已下载 %d 个资源", len(downloaded)), + "downloaded": downloaded, + }) }, }, } diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index aa31664..93d6ff1 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "testing" @@ -324,12 +326,82 @@ func TestReleaseUpdateRejectsInvalidBoolBeforeFetch(t *testing.T) { } } +func TestReleaseDownload(t *testing.T) { + tmpDir := t.TempDir() + assetContent := "binary-payload-here" + + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/1.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "tag_name": "v1.0", + "name": "First release", + "assets": []interface{}{ + map[string]interface{}{ + "url": "/assets/app.tar.gz", + "filename": "app.tar.gz", + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/assets/app.tar.gz": + w.Header().Set("Content-Type", "application/octet-stream") + w.Write([]byte(assetContent)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + "output": tmpDir, + }) + err := common.RunShortcut(t, Shortcuts(), "download", ctx) + if err != nil { + t.Fatalf("download failed: %v", err) + } + + // Verify file was written + data, err := os.ReadFile(filepath.Join(tmpDir, "app.tar.gz")) + if err != nil { + t.Fatalf("failed to read downloaded file: %v", err) + } + if string(data) != assetContent { + t.Errorf("file content mismatch: got %q, want %q", string(data), assetContent) + } +} + +func TestReleaseDownloadNoAssets(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/2.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(2), + "tag_name": "v2.0", + "name": "Empty release", + "assets": []interface{}{}, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "2", + }) + err := common.RunShortcut(t, Shortcuts(), "download", ctx) + if err != nil { + t.Fatalf("download with no assets failed: %v", err) + } +} + func TestReleaseShortcutNames(t *testing.T) { got := map[string]bool{} for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - want := []string{"list", "create", "edit", "view", "update", "delete"} + want := []string{"list", "create", "edit", "view", "update", "delete", "download"} for _, name := range want { if !got[name] { t.Fatalf("missing shortcut %q in %v", name, got) @@ -452,4 +524,5 @@ func ExampleShortcuts() { // view // update // delete + // download } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..0677b93 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -199,12 +199,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { // 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) + return fmt.Errorf("获取当前用户信息失败: %w", err) } userData, _ := userEnv.Data.(map[string]interface{}) login, _ := userData["login"].(string) if login == "" { - return fmt.Errorf("cannot determine current user login") + return fmt.Errorf("无法确定当前用户") } userID, _ := userData["user_id"].(float64) body := map[string]interface{}{ diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index a0ee4c2..5814783 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -52,6 +53,62 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "issues", + Description: tr.T("cmd.search.issues.short"), + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "category", Short: "c", Usage: tr.T("flag.search.issues.category"), Default: "all"}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.search.issues.assignee")}, + {Name: "author", Usage: tr.T("flag.search.issues.author")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.search.issues.milestone")}, + {Name: "tag", Short: "t", Usage: tr.T("flag.search.issues.tag")}, + {Name: "sort-by", Usage: tr.T("flag.sort_by"), Default: "updated_on"}, + {Name: "sort-dir", Usage: tr.T("flag.sort_direction"), Default: "desc"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + 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 c := ctx.Arg("category"); c != "" { + q.Set("category", c) + } + if a := ctx.Arg("assignee"); a != "" { + q.Set("assigner_id", a) + } + if a := ctx.Arg("author"); a != "" { + q.Set("author_id", a) + } + if m := ctx.Arg("milestone"); m != "" { + q.Set("milestone_id", m) + } + if t := ctx.Arg("tag"); t != "" { + q.Set("issue_tag_ids", t) + } + if s := ctx.Arg("sort-by"); s != "" { + q.Set("sort_by", "issues."+s) + } + if d := ctx.Arg("sort-dir"); d != "" { + q.Set("sort_direction", d) + } + env, err := ctx.CallAPIWithQuery("GET", + fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/search/search_test.go b/shortcuts/search/search_test.go index 8578f6f..5bc496c 100644 --- a/shortcuts/search/search_test.go +++ b/shortcuts/search/search_test.go @@ -1,112 +1,107 @@ package search import ( - "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" - "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(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 writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- repos --- - -func TestSearchRepos(t *testing.T) { +func TestSearchIssuesWithKeyword(t *testing.T) { + var requestQuery string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/projects.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.URL.Query().Get("search") != "golang" { - t.Fatalf("expected search=golang, got %s", r.URL.Query().Get("search")) - } - writeJSON(w, []interface{}{ - map[string]interface{}{"name": "golang-project"}, + requestQuery = r.URL.RawQuery + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(2), + "opened_count": float64(1), + "closed_count": float64(1), + "issues": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "subject": "Fix login bug", + "project_issues_index": float64(10), + "status_name": "新增", + }, + map[string]interface{}{ + "id": float64(2), + "subject": "Update login page", + "project_issues_index": float64(11), + "status_name": "关闭", + }, + }, }) })) defer server.Close() - err := runShortcut(t, server, "repos", map[string]string{"keyword": "golang", "page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "keyword": "login", + }) + err := common.RunShortcut(t, Shortcuts(), "issues", ctx) if err != nil { - t.Fatalf("repos failed: %v", err) + t.Fatalf("search issues failed: %v", err) + } + + if !strings.Contains(requestQuery, "keyword=login") { + t.Errorf("expected keyword param, got: %s", requestQuery) } } -// --- users --- - -func TestSearchUsers(t *testing.T) { +func TestSearchIssuesWithAllFilters(t *testing.T) { + var requestQuery string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/list.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.URL.Query().Get("search") != "alice" { - t.Fatalf("expected search=alice, got %s", r.URL.Query().Get("search")) - } - writeJSON(w, []interface{}{ - map[string]interface{}{"login": "alice"}, + requestQuery = r.URL.RawQuery + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "opened_count": float64(1), + "closed_count": float64(0), + "issues": []interface{}{}, }) })) defer server.Close() - err := runShortcut(t, server, "users", map[string]string{"keyword": "alice", "page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "keyword": "bug", + "category": "opened", + "assignee": "42", + "author": "10", + "milestone": "5", + "tag": "1,2", + "sort-by": "created_on", + "sort-dir": "asc", + }) + err := common.RunShortcut(t, Shortcuts(), "issues", ctx) if err != nil { - t.Fatalf("users failed: %v", err) + t.Fatalf("search issues with filters failed: %v", err) + } + + checks := []string{ + "keyword=bug", + "category=opened", + "assigner_id=42", + "author_id=10", + "milestone_id=5", + "issue_tag_ids=1%2C2", + "sort_by=issues.created_on", + "sort_direction=asc", + } + for _, want := range checks { + if !strings.Contains(requestQuery, want) { + t.Errorf("missing query param %q in: %s", want, requestQuery) + } } } -// --- HTTP error paths --- - -func TestSearchReposHTTPError(t *testing.T) { +func TestSearchIssuesRequiresKeyword(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + t.Fatal("no request should be made without keyword") })) defer server.Close() - err := runShortcut(t, server, "repos", map[string]string{"keyword": "test", "page": "1", "limit": "20"}) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "issues", ctx) if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestSearchUsersHTTPError(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, "users", map[string]string{"keyword": "test", "page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + t.Fatal("expected error when keyword is missing, got nil") } } diff --git a/shortcuts/snippet/snippet.go b/shortcuts/snippet/snippet.go new file mode 100644 index 0000000..717045b --- /dev/null +++ b/shortcuts/snippet/snippet.go @@ -0,0 +1,391 @@ +package snippet + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/snippet" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// testStorePath overrides the snippet store file path. Empty means use default. +// This variable exists for testing only. +var testStorePath string + +func getStore() *snippet.SnippetStore { + if testStorePath != "" { + return snippet.NewSnippetStoreWithPath(testStorePath) + } + return snippet.NewSnippetStore() +} + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "create", + Description: "Create a new code snippet", + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "Snippet title", Required: true}, + {Name: "language", Short: "l", Usage: "Programming language"}, + {Name: "tags", Short: "g", Usage: "Tags (comma-separated)"}, + {Name: "content", Short: "c", Usage: "Snippet content (- for stdin)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + title, err := ctx.RequireArg("title") + if err != nil { + return err + } + content, err := readContent(ctx) + if err != nil { + return err + } + + now := time.Now() + s := snippet.Snippet{ + ID: snippet.GenerateID(), + Title: title, + Language: ctx.Arg("language"), + Tags: parseTags(ctx.Arg("tags")), + Content: content, + CreatedAt: now, + UpdatedAt: now, + } + + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + snippets = append(snippets, s) + if err := store.Save(snippets); err != nil { + return fmt.Errorf("保存代码片段失败: %w", err) + } + return ctx.OutputData(s) + }, + }, + { + Name: "list", + Description: "List all saved code snippets", + Flags: []common.Flag{ + {Name: "tag", Short: "t", Usage: "Filter by tag"}, + {Name: "language", Short: "l", Usage: "Filter by language"}, + {Name: "keyword", Short: "k", Usage: "Filter by keyword in title"}, + }, + Run: func(ctx *common.RuntimeContext) error { + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + + filtered := filterSnippets(snippets, ctx) + + var summaries []map[string]interface{} + for _, s := range filtered { + summaries = append(summaries, toSummary(s)) + } + if summaries == nil { + summaries = []map[string]interface{}{} + } + return ctx.OutputData(summaries) + }, + }, + { + Name: "view", + Description: "View a saved code snippet", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Snippet ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + s, _ := findByID(snippets, id) + if s == nil { + return fmt.Errorf("代码片段 %s 不存在", id) + } + return ctx.OutputData(s) + }, + }, + { + Name: "search", + Description: "Full-text search across snippets", + Flags: []common.Flag{ + {Name: "query", Short: "q", Usage: "Search query", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + query, err := ctx.RequireArg("query") + if err != nil { + return err + } + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + + lowerQuery := strings.ToLower(query) + var results []map[string]interface{} + for _, s := range snippets { + if matchesQuery(s, lowerQuery) { + results = append(results, toSummary(s)) + } + } + if results == nil { + results = []map[string]interface{}{} + } + return ctx.OutputData(results) + }, + }, + { + Name: "update", + Description: "Update an existing code snippet", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Snippet ID", Required: true}, + {Name: "title", Short: "t", Usage: "New title"}, + {Name: "language", Short: "l", Usage: "New language"}, + {Name: "tags", Short: "g", Usage: "New tags (comma-separated)"}, + {Name: "content", Short: "c", Usage: "New content"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + + title := ctx.Arg("title") + language := ctx.Arg("language") + tags := ctx.Arg("tags") + content := ctx.Arg("content") + if title == "" && language == "" && tags == "" && content == "" { + return fmt.Errorf("至少需要指定 --title、--language、--tags 或 --content 之一") + } + + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + + s, idx := findByID(snippets, id) + if s == nil { + return fmt.Errorf("代码片段 %s 不存在", id) + } + + if title != "" { + s.Title = title + } + if language != "" { + s.Language = language + } + if tags != "" { + s.Tags = parseTags(tags) + } + if content != "" { + s.Content = content + } + s.UpdatedAt = time.Now() + snippets[idx] = *s + + if err := store.Save(snippets); err != nil { + return fmt.Errorf("保存代码片段失败: %w", err) + } + return ctx.OutputData(s) + }, + }, + { + Name: "delete", + Description: "Delete a saved code snippet", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Snippet ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + + _, idx := findByID(snippets, id) + if idx == -1 { + return fmt.Errorf("代码片段 %s 不存在", id) + } + + remaining := make([]snippet.Snippet, 0, len(snippets)-1) + remaining = append(remaining, snippets[:idx]...) + remaining = append(remaining, snippets[idx+1:]...) + + if err := store.Save(remaining); err != nil { + return fmt.Errorf("保存代码片段失败: %w", err) + } + return ctx.OutputData(map[string]interface{}{ + "message": "代码片段已删除", + "id": id, + }) + }, + }, + { + Name: "export", + Description: "Export a snippet to a file", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Snippet ID", Required: true}, + {Name: "output", Short: "o", Usage: "Output file path (default: stdout)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + store := getStore() + snippets, err := store.Load() + if err != nil { + return fmt.Errorf("读取代码片段失败: %w", err) + } + + s, _ := findByID(snippets, id) + if s == nil { + return fmt.Errorf("代码片段 %s 不存在", id) + } + + outputPath := ctx.Arg("output") + if outputPath != "" { + if err := os.WriteFile(outputPath, []byte(s.Content), 0o644); err != nil { + return fmt.Errorf("导出文件失败: %w", err) + } + return ctx.OutputData(map[string]interface{}{ + "message": "导出成功", + "file": outputPath, + "id": id, + }) + } + // No output file — print content to stdout + fmt.Fprint(os.Stdout, s.Content) + return nil + }, + }, + } +} + +// --- Helper functions --- + +func findByID(snippets []snippet.Snippet, id string) (*snippet.Snippet, int) { + for i, s := range snippets { + if s.ID == id { + return &snippets[i], i + } + } + return nil, -1 +} + +func toSummary(s snippet.Snippet) map[string]interface{} { + return map[string]interface{}{ + "id": s.ID, + "title": s.Title, + "language": s.Language, + "tags": s.Tags, + "updated_at": s.UpdatedAt, + } +} + +func parseTags(raw string) []string { + if raw == "" { + return nil + } + var tags []string + for _, t := range strings.Split(raw, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + return tags +} + +func readContent(ctx *common.RuntimeContext) (string, error) { + content := ctx.Arg("content") + if content == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("读取标准输入失败: %w", err) + } + return string(data), nil + } + if content == "" { + // Check if stdin has data (piped) + info, err := os.Stdin.Stat() + if err == nil && info.Mode()&os.ModeCharDevice == 0 { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("读取标准输入失败: %w", err) + } + return string(data), nil + } + } + return content, nil +} + +func filterSnippets(snippets []snippet.Snippet, ctx *common.RuntimeContext) []snippet.Snippet { + tag := ctx.Arg("tag") + lang := ctx.Arg("language") + keyword := ctx.Arg("keyword") + + var filtered []snippet.Snippet + for _, s := range snippets { + if tag != "" && !hasTag(s, tag) { + continue + } + if lang != "" && !strings.EqualFold(s.Language, lang) { + continue + } + if keyword != "" && !strings.Contains(strings.ToLower(s.Title), strings.ToLower(keyword)) { + continue + } + filtered = append(filtered, s) + } + return filtered +} + +func hasTag(s snippet.Snippet, tag string) bool { + lower := strings.ToLower(tag) + for _, t := range s.Tags { + if strings.ToLower(t) == lower { + return true + } + } + return false +} + +func matchesQuery(s snippet.Snippet, lowerQuery string) bool { + if strings.Contains(strings.ToLower(s.Title), lowerQuery) { + return true + } + if strings.Contains(strings.ToLower(s.Language), lowerQuery) { + return true + } + if strings.Contains(strings.ToLower(s.Content), lowerQuery) { + return true + } + for _, t := range s.Tags { + if strings.Contains(strings.ToLower(t), lowerQuery) { + return true + } + } + return false +} + +// ensure output package is referenced (used in export stdout fallback) +var _ = (*output.Envelope)(nil) diff --git a/shortcuts/snippet/snippet_test.go b/shortcuts/snippet/snippet_test.go new file mode 100644 index 0000000..8389bc5 --- /dev/null +++ b/shortcuts/snippet/snippet_test.go @@ -0,0 +1,284 @@ +package snippet + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/snippet" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// setupTestStore creates a temp dir and overrides the package-level testStorePath. +// Returns a cleanup function to restore the original value. +func setupTestStore(t *testing.T) (storePath string) { + t.Helper() + dir := t.TempDir() + storePath = filepath.Join(dir, "snippets.json") + original := testStorePath + testStorePath = storePath + t.Cleanup(func() { testStorePath = original }) + return storePath +} + +func newCtx(args map[string]string) *common.RuntimeContext { + return &common.RuntimeContext{ + Format: "json", + Args: args, + } +} + +// --- Create tests --- + +func TestSnippetCreate(t *testing.T) { + storePath := setupTestStore(t) + + ctx := newCtx(map[string]string{ + "title": "Hello World", + "language": "go", + "tags": "test,example", + "content": `fmt.Println("hello")`, + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + store := snippet.NewSnippetStoreWithPath(storePath) + snippets, _ := store.Load() + if len(snippets) != 1 { + t.Fatalf("expected 1 snippet, got %d", len(snippets)) + } + if snippets[0].Title != "Hello World" { + t.Errorf("title mismatch: got %s", snippets[0].Title) + } + if snippets[0].Language != "go" { + t.Errorf("language mismatch: got %s", snippets[0].Language) + } + if len(snippets[0].Tags) != 2 { + t.Errorf("expected 2 tags, got %d", len(snippets[0].Tags)) + } + if snippets[0].Content != `fmt.Println("hello")` { + t.Errorf("content mismatch") + } +} + +func TestSnippetCreateRequiresTitle(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{ + "content": "some code", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err == nil { + t.Fatal("expected error for missing --title") + } +} + +// --- List tests --- + +func TestSnippetList(t *testing.T) { + storePath := setupTestStore(t) + + // Pre-populate + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}}, + {ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}}, + {ID: "c3", Title: "Gamma", Language: "go", Tags: []string{"test", "http"}}, + }) + + ctx := newCtx(map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestSnippetListFilterByTag(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}}, + {ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}}, + {ID: "c3", Title: "Gamma", Language: "go", Tags: []string{"test", "http"}}, + }) + + ctx := newCtx(map[string]string{"tag": "test"}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) + if err != nil { + t.Fatalf("list --tag test failed: %v", err) + } +} + +func TestSnippetListFilterByLanguage(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}}, + {ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}}, + }) + + ctx := newCtx(map[string]string{"language": "go"}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) + if err != nil { + t.Fatalf("list --language go failed: %v", err) + } +} + +// --- View tests --- + +func TestSnippetView(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "abc12345", Title: "Hello", Language: "go", Content: "code"}, + }) + + ctx := newCtx(map[string]string{"id": "abc12345"}) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) + if err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestSnippetViewNotFound(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{"id": "nonexistent"}) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) + if err == nil { + t.Fatal("expected error for nonexistent ID") + } +} + +// --- Search tests --- + +func TestSnippetSearch(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "a1", Title: "HTTP Handler", Language: "go", Content: "func handler()"}, + {ID: "b2", Title: "Sort Algorithm", Language: "python", Content: "def sort(arr)"}, + }) + + ctx := newCtx(map[string]string{"query": "handler"}) + err := common.RunShortcut(t, Shortcuts(), "search", ctx) + if err != nil { + t.Fatalf("search failed: %v", err) + } +} + +// --- Update tests --- + +func TestSnippetUpdate(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "abc12345", Title: "Old Title", Language: "go", Tags: []string{"old"}, Content: "old code"}, + }) + + ctx := newCtx(map[string]string{ + "id": "abc12345", + "title": "New Title", + }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err != nil { + t.Fatalf("update failed: %v", err) + } + + loaded, _ := store.Load() + if loaded[0].Title != "New Title" { + t.Errorf("title not updated: got %s", loaded[0].Title) + } + if loaded[0].Content != "old code" { + t.Errorf("content should not change: got %s", loaded[0].Content) + } +} + +func TestSnippetUpdateRequiresField(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{"id": "abc12345"}) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err == nil { + t.Fatal("expected error when no fields provided") + } +} + +func TestSnippetUpdateNotFound(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{"id": "nonexistent", "title": "X"}) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err == nil { + t.Fatal("expected error for nonexistent ID") + } +} + +// --- Delete tests --- + +func TestSnippetDelete(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "a1", Title: "Keep"}, + {ID: "b2", Title: "Delete Me"}, + }) + + ctx := newCtx(map[string]string{"id": "b2"}) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) + } + + loaded, _ := store.Load() + if len(loaded) != 1 { + t.Fatalf("expected 1 snippet after delete, got %d", len(loaded)) + } + if loaded[0].ID != "a1" { + t.Errorf("wrong snippet remained: got %s", loaded[0].ID) + } +} + +func TestSnippetDeleteNotFound(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{"id": "nonexistent"}) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err == nil { + t.Fatal("expected error for nonexistent ID") + } +} + +// --- Export tests --- + +func TestSnippetExportToFile(t *testing.T) { + storePath := setupTestStore(t) + store := snippet.NewSnippetStoreWithPath(storePath) + store.Save([]snippet.Snippet{ + {ID: "abc12345", Title: "Hello", Content: "package main\nfunc main() {}"}, + }) + + outFile := filepath.Join(t.TempDir(), "main.go") + ctx := newCtx(map[string]string{ + "id": "abc12345", + "output": outFile, + }) + err := common.RunShortcut(t, Shortcuts(), "export", ctx) + if err != nil { + t.Fatalf("export failed: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + if string(data) != "package main\nfunc main() {}" { + t.Errorf("export content mismatch: got %q", string(data)) + } +} + +func TestSnippetExportNotFound(t *testing.T) { + setupTestStore(t) + ctx := newCtx(map[string]string{"id": "nonexistent"}) + err := common.RunShortcut(t, Shortcuts(), "export", ctx) + if err == nil { + t.Fatal("expected error for nonexistent ID") + } +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 563db88..7591f73 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,7 @@ package user import ( "fmt" + "net/url" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -39,6 +40,74 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "headmaps", + Description: tr.T("cmd.user.headmaps.short"), + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/headmaps", login), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + newStatsShortcut(tr, "stats-activity", tr.T("cmd.user.stats_activity.short"), "activity"), + newStatsShortcut(tr, "stats-develop", tr.T("cmd.user.stats_develop.short"), "develop"), + newStatsShortcut(tr, "stats-role", tr.T("cmd.user.stats_role.short"), "role"), + newStatsShortcut(tr, "stats-major", tr.T("cmd.user.stats_major.short"), "major"), + { + Name: "trends", + Description: tr.T("cmd.user.trends.short"), + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/project_trends", login), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +// newStatsShortcut 生成用户统计类 shortcut,消除 stats-activity/develop/role/major 的重复代码。 +func newStatsShortcut(tr *i18n.Translator, name, desc, subPath string) *common.Shortcut { + return &common.Shortcut{ + Name: name, + Description: desc, + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", + fmt.Sprintf("/users/%s/statistics/%s", login, subPath), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, } } diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..7e242bf 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -1,121 +1,212 @@ package user 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 runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(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 writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- me --- - func TestUserMe(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/me.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/me.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "login": "alice", + "user_id": float64(42), + "name": "Alice", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{ - "login": "currentuser", - "name": "Current User", - "id": float64(1), - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "me", nil) + ctx := common.NewTestContext(t, server, "", "", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "me", ctx) if err != nil { t.Fatalf("me failed: %v", err) } } -// --- info --- - func TestUserInfo(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/alice.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/bob.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "login": "bob", + "user_id": float64(7), + "name": "Bob", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{ - "login": "alice", - "name": "Alice", - }) - })) + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "login": "bob", + }) + err := common.RunShortcut(t, Shortcuts(), "info", ctx) if err != nil { t.Fatalf("info failed: %v", err) } } -func TestUserInfoMissingLogin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - })) - defer server.Close() +func TestUserShortcutsMissingLogin(t *testing.T) { + tests := []string{"info", "headmaps", "trends"} + for _, name := range tests { + t.Run(name, func(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no request should be made: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() - err := runShortcut(t, server, "info", map[string]string{}) - if err == nil { - t.Fatal("expected error for missing login") + ctx := common.NewTestContext(t, server, "", "", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), name, ctx) + if err == nil { + t.Fatal("expected error for missing --login") + } + }) } } -// --- HTTP error paths --- - -func TestUserMeHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +func TestUserHeadmaps(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/alice/headmaps.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "headmaps": []interface{}{ + map[string]interface{}{"date": "2025-01-01", "count": float64(5)}, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runShortcut(t, server, "me", nil) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "login": "alice", + }) + err := common.RunShortcut(t, Shortcuts(), "headmaps", ctx) + if err != nil { + t.Fatalf("headmaps failed: %v", err) } } -func TestUserInfoHTTPError(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() +func TestUserStatsEndpoints(t *testing.T) { + tests := []struct { + name string + shortcut string + wantPath string + respData map[string]interface{} + }{ + { + name: "activity", shortcut: "stats-activity", + wantPath: "/users/alice/statistics/activity.json", + respData: map[string]interface{}{"dates": []string{"2025-01-01"}, "commits_count": []float64{3}}, + }, + { + name: "develop", shortcut: "stats-develop", + wantPath: "/users/alice/statistics/develop.json", + respData: map[string]interface{}{"score": float64(80)}, + }, + { + name: "role", shortcut: "stats-role", + wantPath: "/users/alice/statistics/role.json", + respData: map[string]interface{}{"role": "developer"}, + }, + { + name: "major", shortcut: "stats-major", + wantPath: "/users/alice/statistics/major.json", + respData: map[string]interface{}{"major": "backend"}, + }, + } + for _, tt := range tests { + // 功能测试 + t.Run(tt.name, func(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == tt.wantPath { + common.WriteJSON(t, w, tt.respData) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) - if err == nil { - t.Fatal("expected error for HTTP 500") + ctx := common.NewTestContext(t, server, "", "", map[string]string{"login": "alice"}) + err := common.RunShortcut(t, Shortcuts(), tt.shortcut, ctx) + if err != nil { + t.Fatalf("%s failed: %v", tt.shortcut, err) + } + }) + + // MissingLogin 测试 + t.Run(tt.name+"_missing_login", func(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no request should be made: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), tt.shortcut, ctx) + if err == nil { + t.Fatal("expected error for missing --login") + } + }) + } +} + +func TestUserTrends(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/alice/project_trends.json" { + // 验证分页参数 + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(69), + "project_trends": []interface{}{ + map[string]interface{}{"id": float64(1), "name": "trend1"}, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "login": "alice", + "page": "1", + "limit": "20", + }) + err := common.RunShortcut(t, Shortcuts(), "trends", ctx) + if err != nil { + t.Fatalf("trends failed: %v", err) + } +} + +func TestUserTrendsDefaultPagination(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/users/alice/project_trends.json" { + // 不传 page/limit 时,Arg 返回空字符串,url.Values.Set 设置空值 + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(69), + "project_trends": []interface{}{}, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "", "", map[string]string{ + "login": "alice", + }) + err := common.RunShortcut(t, Shortcuts(), "trends", ctx) + if err != nil { + t.Fatalf("trends failed: %v", err) } } diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 7d0574a..1321786 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -2,38 +2,78 @@ package webhook import ( "fmt" + "net/url" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -var allowedWebhookTypes = map[string]bool{ - "gitea": true, "slack": true, "discord": true, "dingtalk": true, "telegram": true, - "msteams": true, "feishu": true, "matrix": true, "jianmu": true, "softbot": true, -} - -var allowedWebhookContentTypes = map[string]bool{"json": true, "form": true} -var allowedWebhookMethods = map[string]bool{"GET": true, "POST": true} - -var allowedWebhookEvents = map[string]bool{ - "push": true, "create": true, "delete": true, - "issues_only": true, "issue_assign": true, "issue_label": true, "issue_comment": true, - "pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true, -} - -// Shortcuts returns webhook management shortcuts. +// Shortcuts returns all shortcuts for webhook management. func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return []*common.Shortcut{ { Name: "list", Description: tr.T("cmd.webhook.list.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", webhookPath(ctx), nil) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/webhooks", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: tr.T("cmd.webhook.create.short"), + Flags: []common.Flag{ + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + webhookURL, err := ctx.RequireArg("url") + if err != nil { + return err + } + body := map[string]interface{}{ + "url": webhookURL, + "content_type": ctx.Arg("content-type"), + "http_method": "POST", + "active": true, + } + if secret := ctx.Arg("secret"); secret != "" { + body["secret"] = secret + } + if events := ctx.Arg("events"); events != "" { + body["events"] = strings.Split(events, ",") + } else { + body["events"] = []string{"push"} + } + if bf := ctx.Arg("branch-filter"); bf != "" { + body["branch_filter"] = bf + } + env, err := ctx.CallAPI("POST", v1Path(ctx)+"/webhooks", body) if err != nil { return err } @@ -54,47 +94,58 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), nil) if err != nil { return err } return ctx.Output(env) }, }, - { - Name: "create", - Description: tr.T("cmd.webhook.create.short"), - Flags: []common.Flag{ - {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, - {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true}, - {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"}, - {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, - {Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"}, - {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, - {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"}, - {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, - }, - Run: runCreate, - }, { Name: "update", Description: tr.T("cmd.webhook.update.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, - {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")}, - {Name: "content-type", Usage: tr.T("flag.webhook.content_type")}, - {Name: "http-method", Usage: tr.T("flag.webhook.http_method")}, - {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")}, {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, - {Name: "active", Usage: tr.T("flag.webhook.active")}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + body := map[string]interface{}{ + "content_type": ctx.Arg("content-type"), + "http_method": "POST", + "active": true, + "branch_filter": ctx.Arg("branch-filter"), + "secret": ctx.Arg("secret"), + } + if webhookURL := ctx.Arg("url"); webhookURL != "" { + body["url"] = webhookURL + } + if events := ctx.Arg("events"); events != "" { + body["events"] = strings.Split(events, ",") + } else { + body["events"] = []string{"push"} + } + env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), body) + if err != nil { + return err + } + return ctx.Output(env) }, - Run: runUpdate, }, { - Name: "delete", - Description: tr.T("cmd.webhook.delete.short"), + Name: "history", + Description: tr.T("cmd.webhook.tasks.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, @@ -106,7 +157,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("DELETE", webhookItemPath(ctx, id), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1Path(ctx), id), nil) if err != nil { return err } @@ -127,7 +178,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/tests", webhookItemPath(ctx, id)), nil) + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1Path(ctx), id), nil) if err != nil { return err } @@ -135,8 +186,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "tasks", - Description: tr.T("cmd.webhook.tasks.short"), + Name: "delete", + Description: tr.T("cmd.webhook.delete.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, @@ -148,7 +199,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil) + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), nil) if err != nil { return err } @@ -158,221 +209,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} - -func runCreate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - payload, err := webhookPayloadFromArgs(ctx, nil) - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", webhookPath(ctx), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - - current, err := fetchWebhook(ctx, id) - if err != nil { - return fmt.Errorf("fetch webhook: %w", err) - } - payload, err := webhookPayloadFromArgs(ctx, current) - if err != nil { - return err - } - env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, id), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func webhookPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo) -} - -func webhookItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", webhookPath(ctx), id) -} - -func fetchWebhook(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { - env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil) - if err != nil { - return nil, err - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("failed to parse webhook data") - } - return data, nil -} - -func webhookPayloadFromArgs(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) { - url := firstNonEmpty(ctx.Arg("url"), stringFromMap(current, "url")) - if url == "" { - return nil, fmt.Errorf("required flag --url is missing") - } - - eventValue := ctx.Arg("events") - var events []string - var err error - if eventValue != "" { - events, err = parseWebhookEvents(eventValue) - if err != nil { - return nil, err - } - } else { - events, err = eventsFromMap(current) - if err != nil { - return nil, err - } - } - if len(events) == 0 { - return nil, fmt.Errorf("required flag --events is missing") - } - - webhookType := strings.ToLower(firstNonEmpty(ctx.Arg("type"), stringFromMap(current, "type"), "gitea")) - if err := validateOneOf("type", webhookType, allowedWebhookTypes); err != nil { - return nil, err - } - contentType := strings.ToLower(firstNonEmpty(ctx.Arg("content-type"), stringFromMap(current, "content_type"), "json")) - if err := validateOneOf("content-type", contentType, allowedWebhookContentTypes); err != nil { - return nil, err - } - httpMethod := strings.ToUpper(firstNonEmpty(ctx.Arg("http-method"), stringFromMap(current, "http_method"), "POST")) - if err := validateOneOf("http-method", httpMethod, allowedWebhookMethods); err != nil { - return nil, err - } - branchFilter := firstNonEmpty(ctx.Arg("branch-filter"), stringFromMap(current, "branch_filter"), "*") - active, err := activeFromArgs(ctx.Arg("active"), current) - if err != nil { - return nil, err - } - - payload := map[string]interface{}{ - "type": webhookType, - "active": active, - "content_type": contentType, - "http_method": httpMethod, - "url": url, - "branch_filter": branchFilter, - "events": events, - } - if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" { - payload["secret"] = secret - } - return payload, nil -} - -func parseWebhookEvents(value string) ([]string, error) { - parts := strings.Split(value, ",") - events := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - event := strings.TrimSpace(part) - if event == "" { - continue - } - if !allowedWebhookEvents[event] { - return nil, fmt.Errorf("invalid --events value %q", event) - } - if seen[event] { - continue - } - seen[event] = true - events = append(events, event) - } - if len(events) == 0 { - return nil, fmt.Errorf("required flag --events is missing") - } - return events, nil -} - -func eventsFromMap(values map[string]interface{}) ([]string, error) { - if values == nil { - return nil, nil - } - raw, ok := values["events"] - if !ok || raw == nil { - return nil, nil - } - switch events := raw.(type) { - case []interface{}: - result := make([]string, 0, len(events)) - for _, event := range events { - name, ok := event.(string) - if !ok { - return nil, fmt.Errorf("failed to parse webhook events") - } - result = append(result, name) - } - return result, nil - case []string: - return events, nil - default: - return nil, fmt.Errorf("failed to parse webhook events") - } -} - -func activeFromArgs(value string, current map[string]interface{}) (bool, error) { - if value != "" { - switch strings.ToLower(strings.TrimSpace(value)) { - case "true": - return true, nil - case "false": - return false, nil - default: - return false, fmt.Errorf("invalid --active value %q: use true or false", value) - } - } - if current != nil { - if active, ok := current["active"].(bool); ok { - return active, nil - } - if active, ok := current["is_active"].(bool); ok { - return active, nil - } - } - return true, nil -} - -func stringFromMap(values map[string]interface{}, key string) string { - if values == nil { - return "" - } - value, _ := values[key].(string) - return value -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func validateOneOf(name, value string, allowed map[string]bool) error { - if allowed[value] { - return nil - } - return fmt.Errorf("invalid --%s value %q", name, value) +func v1Path(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go index 451ff6d..8a3da01 100644 --- a/shortcuts/webhook/webhook_test.go +++ b/shortcuts/webhook/webhook_test.go @@ -1,255 +1,185 @@ package webhook import ( - "encoding/json" "net/http" - "net/http/httptest" - "reflect" "testing" - "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestWebhookList(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/webhooks.json") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "webhooks": []interface{}{}}) - }) - defer server.Close() - - if err := runWebhookShortcut(t, server, "list", nil); err != nil { - t.Fatalf("list shortcut failed: %v", err) - } -} - -func TestWebhookView(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7.json") - writeJSON(t, w, map[string]interface{}{"id": 7, "url": "https://example.com/hook"}) - }) - defer server.Close() - - if err := runWebhookShortcut(t, server, "view", map[string]string{"id": "7"}); err != nil { - t.Fatalf("view shortcut failed: %v", err) - } -} - -func TestWebhookCreatePayload(t *testing.T) { - var payload map[string]interface{} - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/webhooks.json") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"id": 1}) - }) - defer server.Close() - - err := runWebhookShortcut(t, server, "create", map[string]string{ - "url": "https://example.com/hook", - "events": "push,issues_only,push", - "type": "gitea", - "content-type": "json", - "http-method": "POST", - "secret": "secret-token", - "branch-filter": "master,{release*}", - "active": "true", - }) - if err != nil { - t.Fatalf("create shortcut failed: %v", err) - } - - assertEqual(t, payload["url"], "https://example.com/hook") - assertEqual(t, payload["type"], "gitea") - assertEqual(t, payload["content_type"], "json") - assertEqual(t, payload["http_method"], "POST") - assertEqual(t, payload["secret"], "secret-token") - assertEqual(t, payload["branch_filter"], "master,{release*}") - assertEqual(t, payload["active"], true) - assertStringSlice(t, payload["events"], []string{"push", "issues_only"}) -} - -func TestWebhookUpdatePreservesCurrentFields(t *testing.T) { - var payload map[string]interface{} - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/7.json": - writeJSON(t, w, map[string]interface{}{ - "id": 7, - "url": "https://old.example.com/hook", - "type": "gitea", - "content_type": "json", - "http_method": "POST", - "branch_filter": "*", - "events": []string{"push"}, - "active": true, + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "webhooks": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "url": "https://example.com/hook", + "active": true, + }, + }, }) - case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/7.json": - payload = decodeJSON(t, r) - writeJSON(t, w, payload) - default: + } else { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } }) defer server.Close() - err := runWebhookShortcut(t, server, "update", map[string]string{ - "id": "7", - "url": "https://new.example.com/hook", - "events": "push,issue_comment", - }) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { - t.Fatalf("update shortcut failed: %v", err) + t.Fatalf("list failed: %v", err) + } +} + +func TestWebhookCreate(t *testing.T) { + var createPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks.json" { + createPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(2), + "message": "创建成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "url": "https://example.com/hook", + "content-type": "json", + "events": "push,issues", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err != nil { + t.Fatalf("create failed: %v", err) } - assertEqual(t, payload["url"], "https://new.example.com/hook") - assertEqual(t, payload["type"], "gitea") - assertEqual(t, payload["content_type"], "json") - assertEqual(t, payload["http_method"], "POST") - assertEqual(t, payload["branch_filter"], "*") - assertEqual(t, payload["active"], true) - assertStringSlice(t, payload["events"], []string{"push", "issue_comment"}) + common.AssertEqual(t, createPayload["url"], "https://example.com/hook") } func TestWebhookDelete(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/v1/owner/repo/webhooks/7.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runWebhookShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } -} - -func TestWebhookTestDelivery(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/webhooks/7/tests.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runWebhookShortcut(t, server, "test", map[string]string{"id": "7"}); err != nil { - t.Fatalf("test shortcut failed: %v", err) - } -} - -func TestWebhookTasks(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7/hooktasks.json") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "hooktasks": []interface{}{}}) - }) - defer server.Close() - - if err := runWebhookShortcut(t, server, "tasks", map[string]string{"id": "7"}); err != nil { - t.Fatalf("tasks shortcut failed: %v", err) - } -} - -func TestParseWebhookEventsRejectsInvalidEvent(t *testing.T) { - _, err := parseWebhookEvents("push,invalid") - if err == nil { - t.Fatal("expected invalid event to return an error") - } -} - -func TestWebhookCreateRejectsInvalidActive(t *testing.T) { - server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid active should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runWebhookShortcut(t, server, "create", map[string]string{ - "url": "https://example.com/hook", - "events": "push", - "active": "maybe", - }) - if err == nil { - t.Fatal("expected invalid active to return an error") - } -} - -func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findWebhookShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findWebhookShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "删除成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - } - t.Fatalf("shortcut %q not found", name) - return nil -} + }) + defer server.Close() -func newWebhookTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) } } -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) - } -} - -func assertStringSlice(t *testing.T, got interface{}, want []string) { - t.Helper() - values, ok := got.([]interface{}) - if !ok { - t.Fatalf("got %T, want []interface{}", got) - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, ok := value.(string) - if !ok { - t.Fatalf("got event %v (%T), want string", value, value) +func TestWebView(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "url": "https://example.com/hook", + "active": true, + "content_type": "json", + "events": []string{"push"}, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - result = append(result, text) - } - if !reflect.DeepEqual(result, want) { - t.Fatalf("got %v, want %v", result, want) + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) + if err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestWebUpdate(t *testing.T) { + var updatePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" { + updatePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "url": "https://example.com/updated", + "message": "更新成功", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + "url": "https://example.com/updated", + "events": "push,issues", + }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err != nil { + t.Fatalf("update failed: %v", err) + } + + common.AssertEqual(t, updatePayload["url"], "https://example.com/updated") + common.AssertEqual(t, updatePayload["http_method"], "POST") +} + +func TestWebHistory(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1/hooktasks.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": 2, + "hooktasks": []interface{}{ + map[string]interface{}{"id": float64(10), "status": "succeeded"}, + map[string]interface{}{"id": float64(11), "status": "failed"}, + }, + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "history", ctx) + if err != nil { + t.Fatalf("history failed: %v", err) + } +} + +func TestWebTest(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/1/tests.json" { + common.WriteJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + } else { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "test", ctx) + if err != nil { + t.Fatalf("test failed: %v", err) } } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 539b7a1..839ef43 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -2,199 +2,719 @@ package wiki import ( "encoding/base64" + "encoding/json" "fmt" "net/url" + "strconv" + "strings" + "time" - "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// switchToGateway overrides the client base URL with the gateway URL from config. -func switchToGateway(ctx *common.RuntimeContext) error { - cfg, err := config.Load() - if err != nil { - return err - } - if cfg.GatewayURL == "" { - cfg.GatewayURL = config.DefaultGatewayURL - } - ctx.Client.BaseURL = cfg.GatewayURL - return nil -} +const wikiBaseURL = "https://gateway.gitlink.org.cn/api" -// gatewayFlag returns the common --gateway flag definition. -func gatewayFlag() common.Flag { - return common.Flag{Name: "gateway", Short: "g", Usage: "Use gateway API endpoint", Bool: true} -} - -// Shortcuts returns all wiki shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", Description: "List wiki pages", - Flags: []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, - gatewayFlag(), - }, Run: func(ctx *common.RuntimeContext) error { - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } if err := ctx.ResolveOwnerRepo(); err != nil { return err } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } q := url.Values{} q.Set("owner", ctx.Owner) q.Set("repo", ctx.Repo) - q.Set("projectId", ctx.Arg("project-id")) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q) - if err != nil { - return err - } - return ctx.Output(env) + q.Set("projectId", strconv.Itoa(projectID)) + return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q) }, }, { Name: "view", - Description: "View a wiki page by page name", + Description: "View a wiki page", Flags: []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - gatewayFlag(), + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } if err := ctx.ResolveOwnerRepo(); err != nil { return err } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } q := url.Values{} q.Set("owner", ctx.Owner) q.Set("repo", ctx.Repo) - q.Set("projectId", ctx.Arg("project-id")) - q.Set("pageName", ctx.Arg("page-name")) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/getWiki", q) - if err != nil { - return err - } - return ctx.Output(env) + q.Set("projectId", strconv.Itoa(projectID)) + q.Set("pageName", name) + return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q) }, }, { Name: "create", - Description: "Create a new wiki page", + Description: "Create a wiki page (optionally in a directory)", Flags: []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - {Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, - {Name: "content", Short: "c", Usage: "Wiki page content (markdown)", Required: true}, + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, + {Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true}, {Name: "message", Short: "m", Usage: "Commit message"}, - gatewayFlag(), + {Name: "dir", Short: "d", Usage: "Parent directory to create page in"}, }, Run: func(ctx *common.RuntimeContext) error { - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - content := ctx.Arg("content") - payload := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": ctx.Arg("project-id"), - "pageName": ctx.Arg("page-name"), - "title": ctx.Arg("title"), - "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), - "message": ctx.Arg("message"), - } - env, err := ctx.CallAPI("POST", "/wiki/open/createWiki", payload) + name, err := ctx.RequireArg("name") if err != nil { return err } - return ctx.Output(env) + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + + // Step 1: Create the wiki page + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": name, + "title": name, + "message": ctx.Arg("message"), + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + if err := callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil); err != nil { + return err + } + + // Step 2: If --dir specified, add page link under that directory in sidebar + if dir := ctx.Arg("dir"); dir != "" { + time.Sleep(1 * time.Second) + if err := addPageToSidebarDir(ctx, projectID, name, dir); err != nil { + fmt.Printf("Page created, but failed to add to directory %q in sidebar: %v\n", dir, err) + } else { + fmt.Printf("Page %q added to directory %q in sidebar.\n", name, dir) + } + } + + return nil }, }, { Name: "update", - Description: "Update an existing wiki page", + Description: "Update a wiki page", Flags: []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - {Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, - {Name: "content", Short: "c", Usage: "Wiki page content (markdown)"}, + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, + {Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true}, {Name: "message", Short: "m", Usage: "Commit message"}, - gatewayFlag(), }, Run: func(ctx *common.RuntimeContext) error { - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title := ctx.Arg("title") - if title == "" { - return fmt.Errorf("--title is required") - } - content := ctx.Arg("content") - payload := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": ctx.Arg("project-id"), - "pageName": ctx.Arg("page-name"), - "title": title, - "message": ctx.Arg("message"), - } - if content != "" { - payload["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content)) - } - env, err := ctx.CallAPI("PUT", "/wiki/open/updateWiki", payload) + name, err := ctx.RequireArg("name") if err != nil { return err } - return ctx.Output(env) + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": name, + "title": name, + "message": ctx.Arg("message"), + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil) }, }, { Name: "delete", - Description: "Delete a wiki page", + Description: "Delete a wiki page and remove it from sidebar", Flags: []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - gatewayFlag(), + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - payload := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": ctx.Arg("project-id"), - "pageName": ctx.Arg("page-name"), - } - env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", payload) + name, err := ctx.RequireArg("name") if err != nil { return err } - return ctx.Output(env) + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + + // Step 1: Delete the wiki page content + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": name, + } + if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil { + return err + } + + // Step 2: Wait for GitLink async sidebar rebuild, then clean up + time.Sleep(2 * time.Second) + cleanSidebar(ctx, projectID, name) + + fmt.Printf("Wiki page %q deleted successfully.\n", name) + return nil + }, + }, + { + Name: "mkdir", + Description: "Create a wiki directory (use --parent for subdirectory)", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Directory name", Required: true}, + {Name: "parent", Short: "p", Usage: "Parent directory name (creates subdirectory)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + parent := ctx.Arg("parent") + + if err := createDirectoryInSidebar(ctx, projectID, name, parent); err != nil { + return err + } + + if parent != "" { + fmt.Printf("Subdirectory %q created under %q.\n", name, parent) + } else { + fmt.Printf("Directory %q created.\n", name) + } + return nil + }, + }, + { + Name: "rmdir", + Description: "Remove a wiki directory from sidebar", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Directory name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + + if err := removeDirectoryFromSidebar(ctx, projectID, name); err != nil { + return err + } + + fmt.Printf("Directory %q removed from sidebar.\n", name) + return nil + }, + }, + { + Name: "rename", + Description: "Rename a wiki page", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Current page name", Required: true}, + {Name: "new-name", Short: "N", Usage: "New page name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + oldName, err := ctx.RequireArg("name") + if err != nil { + return err + } + newName, err := ctx.RequireArg("new-name") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + + if err := renameWikiPage(ctx, projectID, oldName, newName); err != nil { + return err + } + + fmt.Printf("Page renamed from %q to %q.\n", oldName, newName) + return nil + }, + }, + { + Name: "renamedir", + Description: "Rename a wiki directory in sidebar", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Current directory name", Required: true}, + {Name: "new-name", Short: "N", Usage: "New directory name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + oldName, err := ctx.RequireArg("name") + if err != nil { + return err + } + newName, err := ctx.RequireArg("new-name") + if err != nil { + return err + } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + + if err := renameDirectoryInSidebar(ctx, projectID, oldName, newName); err != nil { + return err + } + + fmt.Printf("Directory renamed from %q to %q.\n", oldName, newName) + return nil }, }, } } + +// --------------------------------------------------------------------------- +// Wiki gateway helpers +// --------------------------------------------------------------------------- + +// callWikiAPI temporarily switches the client BaseURL to the wiki gateway. +// In test mode (BaseURL is a local httptest server), the switch is skipped. +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") { + ctx.Client.BaseURL = wikiBaseURL + } + defer func() { ctx.Client.BaseURL = origBase }() + + var env *output.Envelope + var err error + if query != nil { + env, err = ctx.CallAPIRawWithQuery(method, path, query) + } else { + env, err = ctx.CallAPIRaw(method, path, body) + } + if err != nil { + return err + } + return ctx.Output(env) +} + +// callWikiAPISilent is like callWikiAPI but does not print output. +func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") { + ctx.Client.BaseURL = wikiBaseURL + } + defer func() { ctx.Client.BaseURL = origBase }() + + _, err := ctx.CallAPIRaw(method, path, body) + return err +} + +const sidebarPageName = "_Sidebar" + +// readSidebarContent fetches and decodes the _Sidebar content. +func readSidebarContent(ctx *common.RuntimeContext, projectID int) (string, error) { + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", strconv.Itoa(projectID)) + q.Set("pageName", sidebarPageName) + + env, err := ctx.CallAPIRawWithQuery("GET", "/wiki/open/getWiki", q) + if err != nil { + return "", fmt.Errorf("failed to read sidebar: %w", err) + } + + outer, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected sidebar response") + } + + var inner map[string]interface{} + switch v := outer["data"].(type) { + case map[string]interface{}: + inner = v + case string: + if err := json.Unmarshal([]byte(v), &inner); err != nil { + return "", fmt.Errorf("failed to parse sidebar data: %w", err) + } + default: + return "", fmt.Errorf("sidebar data not found") + } + + contentB64, ok := inner["content_base64"].(string) + if !ok { + return "", fmt.Errorf("sidebar content_base64 not found") + } + contentBytes, err := base64.StdEncoding.DecodeString(contentB64) + if err != nil { + return "", fmt.Errorf("failed to decode sidebar: %w", err) + } + return string(contentBytes), nil +} + +// updateSidebarContent writes new content to the _Sidebar page. +func updateSidebarContent(ctx *common.RuntimeContext, projectID int, content, message string) error { + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": sidebarPageName, + "title": sidebarPageName, + "message": message, + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + _, err := ctx.CallAPIRaw("PUT", "/wiki/open/updateWiki", body) + return err +} + +// withWikiGateway temporarily switches BaseURL to the wiki gateway. +func withWikiGateway(ctx *common.RuntimeContext) func() { + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") { + ctx.Client.BaseURL = wikiBaseURL + } + return func() { ctx.Client.BaseURL = origBase } +} + +// --------------------------------------------------------------------------- +// Sidebar manipulation +// --------------------------------------------------------------------------- + +// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it. +func cleanSidebar(ctx *common.RuntimeContext, projectID int, pageName string) { + defer withWikiGateway(ctx)() + + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return + } + + target := "[[" + pageName + "]]" + lines := strings.Split(sidebar, "\n") + var newLines []string + for _, line := range lines { + if strings.TrimSpace(line) != target { + newLines = append(newLines, line) + } + } + newSidebar := strings.Join(newLines, "\n") + if newSidebar == sidebar { + return + } + + updateSidebarContent(ctx, projectID, newSidebar, "Remove deleted page "+pageName+" from sidebar") +} + +// addPageToSidebarDir adds a [[pageName]] link under the specified directory in the sidebar. +func addPageToSidebarDir(ctx *common.RuntimeContext, projectID int, pageName, dirName string) error { + defer withWikiGateway(ctx)() + + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return err + } + + lines := strings.Split(sidebar, "\n") + dirLineIdx := findDirectoryLine(lines, dirName) + if dirLineIdx == -1 { + return fmt.Errorf("directory %q not found in sidebar", dirName) + } + + // Find the insert position: after the last child of this directory + insertIdx := findDirectoryEnd(lines, dirLineIdx) + dirIndent := countIndent(lines[dirLineIdx]) + newLine := strings.Repeat("\t", dirIndent+1) + "[[" + pageName + "]]" + + // Insert the new page link + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:insertIdx]...) + result = append(result, newLine) + result = append(result, lines[insertIdx:]...) + + newSidebar := strings.Join(result, "\n") + return updateSidebarContent(ctx, projectID, newSidebar, "Add page "+pageName+" to directory "+dirName) +} + +// createDirectoryInSidebar creates a new directory entry in the sidebar. +// If parent is empty, creates a top-level directory; otherwise creates a subdirectory. +func createDirectoryInSidebar(ctx *common.RuntimeContext, projectID int, name, parent string) error { + defer withWikiGateway(ctx)() + + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return err + } + + lines := strings.Split(sidebar, "\n") + + if parent == "" { + // Top-level directory: append at the end + newLine := "- " + name + if len(lines) > 0 && lines[len(lines)-1] != "" { + sidebar += "\n" + newLine + } else { + sidebar += newLine + } + } else { + // Subdirectory: find parent and insert under it + parentIdx := findDirectoryLine(lines, parent) + if parentIdx == -1 { + return fmt.Errorf("parent directory %q not found in sidebar", parent) + } + insertIdx := findDirectoryEnd(lines, parentIdx) + parentIndent := countIndent(lines[parentIdx]) + newLine := strings.Repeat("\t", parentIndent+1) + "- " + name + + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:insertIdx]...) + result = append(result, newLine) + result = append(result, lines[insertIdx:]...) + sidebar = strings.Join(result, "\n") + } + + return updateSidebarContent(ctx, projectID, sidebar, "Create directory "+name) +} + +// removeDirectoryFromSidebar removes a directory entry (and its children) from the sidebar. +func removeDirectoryFromSidebar(ctx *common.RuntimeContext, projectID int, dirName string) error { + defer withWikiGateway(ctx)() + + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return err + } + + lines := strings.Split(sidebar, "\n") + dirLineIdx := findDirectoryLine(lines, dirName) + if dirLineIdx == -1 { + return fmt.Errorf("directory %q not found in sidebar", dirName) + } + + // Remove the directory line and all its children (lines with greater indent) + dirIndent := countIndent(lines[dirLineIdx]) + endIdx := dirLineIdx + 1 + for endIdx < len(lines) { + if strings.TrimSpace(lines[endIdx]) == "" { + break + } + if countIndent(lines[endIdx]) <= dirIndent { + break + } + endIdx++ + } + + result := make([]string, 0, len(lines)-(endIdx-dirLineIdx)) + result = append(result, lines[:dirLineIdx]...) + result = append(result, lines[endIdx:]...) + newSidebar := strings.Join(result, "\n") + + return updateSidebarContent(ctx, projectID, newSidebar, "Remove directory "+dirName) +} + +// renameWikiPage renames a page: get content → create new → delete old → update sidebar. +func renameWikiPage(ctx *common.RuntimeContext, projectID int, oldName, newName string) error { + defer withWikiGateway(ctx)() + + // Step 1: Get old page content + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", strconv.Itoa(projectID)) + q.Set("pageName", oldName) + + env, err := ctx.CallAPIRawWithQuery("GET", "/wiki/open/getWiki", q) + if err != nil { + return fmt.Errorf("failed to get page %q: %w", oldName, err) + } + + var contentB64, message string + outer, ok := env.Data.(map[string]interface{}) + if ok { + var inner map[string]interface{} + switch v := outer["data"].(type) { + case map[string]interface{}: + inner = v + case string: + json.Unmarshal([]byte(v), &inner) + } + if inner != nil { + if c, ok := inner["content_base64"].(string); ok { + contentB64 = c + } + if m, ok := inner["message"].(string); ok { + message = m + } + } + } + if contentB64 == "" { + return fmt.Errorf("could not read content of page %q", oldName) + } + + // Step 2: Create new page with old content + createBody := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": newName, + "title": newName, + "message": "Rename from " + oldName, + "content_base64": contentB64, + } + if _, err := ctx.CallAPIRaw("POST", "/wiki/open/createWiki", createBody); err != nil { + return fmt.Errorf("failed to create page %q: %w", newName, err) + } + + // Step 3: Delete old page + deleteBody := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": oldName, + } + ctx.CallAPIRaw("DELETE", "/wiki/open/deleteWiki", deleteBody) + + // Step 4: Update sidebar: [[oldName]] → [[newName]] + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return nil // page renamed, sidebar update is best-effort + } + newSidebar := strings.ReplaceAll(sidebar, "[["+oldName+"]]", "[["+newName+"]]") + if newSidebar != sidebar { + updateSidebarContent(ctx, projectID, newSidebar, "Rename page "+oldName+" to "+newName) + } + + _ = message + return nil +} + +// renameDirectoryInSidebar renames a directory entry in the sidebar. +func renameDirectoryInSidebar(ctx *common.RuntimeContext, projectID int, oldName, newName string) error { + defer withWikiGateway(ctx)() + + sidebar, err := readSidebarContent(ctx, projectID) + if err != nil { + return err + } + + lines := strings.Split(sidebar, "\n") + dirLineIdx := findDirectoryLine(lines, oldName) + if dirLineIdx == -1 { + return fmt.Errorf("directory %q not found in sidebar", oldName) + } + + // Replace the directory name on that line + oldEntry := "- " + oldName + newEntry := "- " + newName + lines[dirLineIdx] = strings.Replace(lines[dirLineIdx], oldEntry, newEntry, 1) + + newSidebar := strings.Join(lines, "\n") + return updateSidebarContent(ctx, projectID, newSidebar, "Rename directory "+oldName+" to "+newName) +} + +// findDirectoryLine returns the line index of "- dirName" in the sidebar lines. +func findDirectoryLine(lines []string, dirName string) int { + target := "- " + dirName + for i, line := range lines { + if strings.TrimSpace(line) == target { + return i + } + } + return -1 +} + +// findDirectoryEnd returns the line index after the last child of the directory at dirLineIdx. +func findDirectoryEnd(lines []string, dirLineIdx int) int { + dirIndent := countIndent(lines[dirLineIdx]) + for i := dirLineIdx + 1; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" { + continue + } + if countIndent(lines[i]) <= dirIndent { + return i + } + } + return len(lines) +} + +// countIndent returns the number of leading tabs in a line. +func countIndent(line string) int { + n := 0 + for _, ch := range line { + if ch == '\t' { + n++ + } else { + break + } + } + return n +} + +// --------------------------------------------------------------------------- +// Project ID resolution +// --------------------------------------------------------------------------- + +func fetchProjectID(ctx *common.RuntimeContext) (int, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("获取项目信息失败: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("无法解析项目信息") + } + if idFloat, ok := data["project_id"].(float64); ok { + return int(idFloat), nil + } + if idFloat, ok := data["repo_id"].(float64); ok { + return int(idFloat), nil + } + if idFloat, ok := data["id"].(float64); ok { + return int(idFloat), nil + } + return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在") +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index f0d95d5..7cf1911 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,224 +1,192 @@ package wiki import ( - "encoding/json" + "encoding/base64" "net/http" - "net/http/httptest" "testing" - "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestWikiList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/wiki/open/wikiPages") - assertEqual(t, r.URL.Query().Get("owner"), "owner") - assertEqual(t, r.URL.Query().Get("repo"), "repo") - assertEqual(t, r.URL.Query().Get("projectId"), "12345") - writeJSON(t, w, map[string]interface{}{"status": 0, "data": []interface{}{}}) - })) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(123), + "name": "repo", + }) + case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages": + common.WriteJSON(t, w, map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"title": "Home", "sub_url": "Home"}, + map[string]interface{}{"title": "Guide", "sub_url": "Guide"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runWikiShortcut(t, server, "list", map[string]string{ - "project-id": "12345", - }) + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) if err != nil { - t.Fatalf("list shortcut failed: %v", err) + t.Fatalf("list failed: %v", err) } } func TestWikiView(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/wiki/open/getWiki") - assertEqual(t, r.URL.Query().Get("owner"), "owner") - assertEqual(t, r.URL.Query().Get("repo"), "repo") - assertEqual(t, r.URL.Query().Get("projectId"), "12345") - assertEqual(t, r.URL.Query().Get("pageName"), "home") - writeJSON(t, w, map[string]interface{}{"status": 0, "data": map[string]interface{}{"title": "home"}}) - })) + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(123), + }) + case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki": + pageName := r.URL.Query().Get("pageName") + if pageName != "Home" { + t.Fatalf("expected pageName=Home, got %s", pageName) + } + common.WriteJSON(t, w, map[string]interface{}{ + "data": map[string]interface{}{ + "title": "Home", + "content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome")), + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runWikiShortcut(t, server, "view", map[string]string{ - "project-id": "12345", - "page-name": "home", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "Home", }) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) if err != nil { - t.Fatalf("view shortcut failed: %v", err) + t.Fatalf("view failed: %v", err) } } func TestWikiCreate(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/wiki/open/createWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) + var createPayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(123), + }) + case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki": + createPayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "code": 201, + "data": map[string]interface{}{"title": "NewPage"}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runWikiShortcut(t, server, "create", map[string]string{ - "project-id": "12345", - "page-name": "new-page", - "title": "New Page", - "content": "# Hello", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "NewPage", + "content": "Hello Wiki", }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) if err != nil { - t.Fatalf("create shortcut failed: %v", err) + t.Fatalf("create failed: %v", err) } - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["repo"], "repo") - assertEqual(t, payload["pageName"], "new-page") - assertEqual(t, payload["title"], "New Page") - if _, ok := payload["content_base64"]; !ok { - t.Fatal("body missing content_base64") - } + common.AssertEqual(t, createPayload["pageName"], "NewPage") + common.AssertEqual(t, createPayload["owner"], "owner") + common.AssertEqual(t, createPayload["repo"], "repo") + + expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki")) + common.AssertEqual(t, createPayload["content_base64"], expectedContent) } func TestWikiUpdate(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PUT", "/wiki/open/updateWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) + var updatePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(123), + }) + case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki": + updatePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "code": 200, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - "title": "Updated Title", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "Home", + "content": "Updated content", + "message": "Update wiki page", }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) if err != nil { - t.Fatalf("update shortcut failed: %v", err) + t.Fatalf("update failed: %v", err) } - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["pageName"], "home") - assertEqual(t, payload["title"], "Updated Title") -} - -func TestWikiUpdateRequiresTitle(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("server should not be called when title is missing: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - }) - if err == nil { - t.Fatal("expected update without --title to return an error") - } - if err.Error() != "--title is required" { - t.Fatalf("unexpected error message: %s", err.Error()) - } -} - -func TestWikiUpdateWithContentOnly(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PUT", "/wiki/open/updateWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - "title": "Existing Title", - "content": "# Updated content", - }) - if err != nil { - t.Fatalf("update with content failed: %v", err) - } - if _, ok := payload["content_base64"]; !ok { - t.Fatal("body missing content_base64 when --content provided") - } + common.AssertEqual(t, updatePayload["pageName"], "Home") + common.AssertEqual(t, updatePayload["message"], "Update wiki page") } func TestWikiDelete(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/wiki/open/deleteWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) + var deletePayload, sidebarUpdatePayload map[string]interface{} + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(123), + }) + case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki": + deletePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "code": 204, + }) + case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki": + pageName := r.URL.Query().Get("pageName") + if pageName != "_Sidebar" { + t.Fatalf("expected pageName=_Sidebar, got %s", pageName) + } + common.WriteJSON(t, w, map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{ + "content_base64": base64.StdEncoding.EncodeToString([]byte("[[OldPage]]\n[[OtherPage]]")), + }, + }) + case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki": + sidebarUpdatePayload = common.DecodeJSON(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "code": 200, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) defer server.Close() - err := runWikiShortcut(t, server, "delete", map[string]string{ - "project-id": "12345", - "page-name": "old-page", + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "name": "OldPage", }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) if err != nil { - t.Fatalf("delete shortcut failed: %v", err) + t.Fatalf("delete failed: %v", err) } - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["repo"], "repo") - assertEqual(t, payload["pageName"], "old-page") -} + common.AssertEqual(t, deletePayload["pageName"], "OldPage") + common.AssertEqual(t, deletePayload["projectId"], float64(123)) -func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findWikiShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findWikiShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) - } -} - -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) - } + // Verify sidebar was updated to remove the deleted page link + common.AssertEqual(t, sidebarUpdatePayload["pageName"], "_Sidebar") + expectedSidebar := base64.StdEncoding.EncodeToString([]byte("[[OtherPage]]")) + common.AssertEqual(t, sidebarUpdatePayload["content_base64"], expectedSidebar) } diff --git a/skills/README.md b/skills/README.md index d507074..1a7572c 100644 --- a/skills/README.md +++ b/skills/README.md @@ -32,7 +32,7 @@ gitlink-cli auth status gitlink-cli user +me ``` -详见: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) +详见: [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md) ### 2. 查看可用命令 @@ -64,19 +64,25 @@ skills/ ├── README.md # 本文件 ├── gitlink-shared/ # 共享基础规则 │ ├── SKILL.md # 认证、全局参数、安全规则、分支约定 -│ └── references/ -│ ├── api-reference.md # API 详细参考、错误处理 -│ ├── raw-api-batch.md # 批量 Raw API 调用参考 -│ └── troubleshooting.md # 常见问题排查 +│ ├── REFERENCE.md # API 详细参考、错误处理 +│ ├── TROUBLESHOOTING.md # 常见问题排查 +│ └── examples/ +│ └── auth-workflow.md # 认证工作流示例 ├── gitlink-repo/ # 仓库管理 │ ├── SKILL.md # 仓库操作指南 -│ └── references/ # 仓库命令参考文档 +│ ├── REFERENCE.md # 仓库 API 参考 +│ └── examples/ +│ └── repo-workflow.md # 仓库管理工作流 ├── gitlink-issue/ # Issue 管理 │ ├── SKILL.md # Issue 操作指南 -│ └── references/ # Issue 命令参考文档 +│ ├── REFERENCE.md # Issue API 参考 +│ └── examples/ +│ └── issue-workflow.md # Issue 全流程工作流 ├── gitlink-pr/ # Pull Request │ ├── SKILL.md # PR 操作指南 -│ └── references/ # PR 命令参考文档 +│ ├── REFERENCE.md # PR API 参考 +│ └── examples/ +│ └── pr-workflow.md # PR 工作流 ├── gitlink-member/ # 仓库成员管理 │ └── SKILL.md # 成员与邀请链接操作指南 ├── gitlink-branch/ # 分支管理 @@ -85,24 +91,25 @@ skills/ │ └── branch-workflow.md # 分支工作流 ├── gitlink-release/ # 版本发布 │ ├── SKILL.md # Release 操作指南 -│ └── references/ # Release 命令参考文档 -├── gitlink-release-auto/ # 自动化 Release 管理 -│ └── SKILL.md # 自动发版、版本号推荐、Release Notes 生成 +│ ├── REFERENCE.md # Release API 参考 +│ └── examples/ +│ └── release-workflow.md # Release 工作流 ├── gitlink-search/ # 搜索功能 │ ├── SKILL.md # 搜索操作指南 -│ └── references/ # 搜索命令参考文档 +│ └── examples/ +│ └── search-workflow.md # 搜索工作流 ├── gitlink-user/ # 用户管理 -│ ├── SKILL.md # 用户操作指南 -│ └── references/ # 用户命令参考文档 +│ └── SKILL.md # 用户操作指南 ├── gitlink-org/ # 组织管理 │ ├── SKILL.md # 组织操作指南 -│ └── references/ # 组织命令参考文档 +│ └── examples/ +│ └── org-workflow.md # 组织工作流 ├── gitlink-ci/ # CI/CD -│ └── SKILL.md # CI 操作指南 +│ ├── SKILL.md # CI 操作指南 +│ └── examples/ +│ └── ci-workflow.md # CI 工作流 ├── gitlink-pipeline/ # 流水线工作流 │ └── SKILL.md # Pipeline 操作指南 -├── gitlink-wiki/ # Wiki 页面管理 -│ └── SKILL.md # Wiki 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -128,11 +135,15 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | -| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | -| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | -| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | +| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +reviews`, `pr +review` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | +| **gitlink-milestone** | 里程碑管理 | `milestone +list`, `milestone +create`, `milestone +view`, `milestone +close` | +| **gitlink-label** | 标签管理 | `label +list`, `label +create`, `label +delete` | +| **gitlink-file** | 仓库文件操作 | `file +browse`, `file +get`, `file +create`, `file +update`, `file +delete` | +| **gitlink-webhook** | Webhook 管理 | `webhook +list`, `webhook +create`, `webhook +delete` | +| **gitlink-member** | 项目成员管理 | `member +list`, `member +add`, `member +remove` | ### 辅助 Skills @@ -143,10 +154,40 @@ skills/ | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | -| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | +| **gitlink-snippet** | 本地代码片段管理 | `snippet +create`, `snippet +search`, `snippet +list` | + +### 智能化与工作流 Skills(AI 编排,串联多步) + +| Skill | 说明 | 常用命令 / 能力 | +|-------|------|----------| +| **gitlink-onboarding** | 新人引导 | 搜 good-first-issue、5 维度友好度评估、生成引导评论 | +| **gitlink-digest** | 项目简报 | 跨源聚合 Issue/PR/CI/通知成日报 | +| **gitlink-todo** | 我的待办 | 汇总 @我 / 分配我 / 待 review,按紧急度排序 | +| **gitlink-pr-guard** | 代码质量看门人 | PR→Review→CI→质量判定→合并(端到端门禁) | + +> 以上 5 个为本次新增的 AI 工作流 Skill,均兼容 Claude Code 等 Agent,详见各 `SKILL.md`。 + +### 科研辅助 Skills(子赛题四「应用 GitLink 辅助科研」) + +采用「Go 出数据 + Python 做算法」:数据复用现有 gitlink-cli 域,科研算法在 `scripts/research/*.py`(networkx/plotly),每个场景配可复现脚本与 Skill 规范,并可通过 `gitlink-cli server` 网页终端演示。详见 [../doc/科研场景使用指南.md](../doc/科研场景使用指南.md)。 + +| Skill | 场景 | 说明 | 命令 | +|-------|------|------|------| +| **gitlink-research-insight** | S1 | 仓库级科研项目洞悉:演进谱系 + 创新点 | `python scripts/research/lineage.py` | +| **gitlink-research-graph** | S2 | 科研知识图谱(networkx 节点/边)+ 热点追踪 | `python scripts/research/graph_build.py` | +| **gitlink-compliance** | S3 | 合规与复现性检查(license/密钥/复现) | `python scripts/research/repro.py` | +| **gitlink-collab-match** | S4 | 科研协作智能匹配(缺口×画像) | `python scripts/research/match.py` | +| **gitlink-research-progress** | S5 | 进度智能跟踪与预警(周报+风险) | `python scripts/research/report.py` | +| **gitlink-research-visual** | S6 | 科研成果可视化(plotly 交互图表) | `python scripts/research/visual.py` | +| gitlink-research-tracker | S2/S5 | 技术调研与热点追踪(含真机 Agent 日志) | 见 SKILL.md | +| gitlink-license-compliance | S3 | 许可证深度合规扫描 | 见 SKILL.md | +| gitlink-scholar-profile | S4/S6 | 学者/团队科研画像 | 见 SKILL.md | +| gitlink-research-fork-impact | S1/S6 | Fork 影响力与想法传播分析 | 见 SKILL.md | + +> 6 个场景均已在真实科研仓库 `mindspore-Ecosystem/mindspore` 上验证;技术实现详见 [../doc/科研场景技术实现报告.md](../doc/科研场景技术实现报告.md)。 --- @@ -163,7 +204,7 @@ gitlink-cli repo +info gitlink-cli repo +info --owner wbtiger --repo gitlink-cli ``` -详见: [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) +详见: [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md) ### 场景 2:创建和管理 Issue @@ -184,7 +225,7 @@ gitlink-cli issue +close -i 123 gitlink-cli issue +batch-close --numbers 123,124 --dry-run ``` -详见: [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) +详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) ### 场景 3:管理分支和发布 @@ -202,7 +243,7 @@ gitlink-cli release +create -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." gitlink-cli release +view -i ``` -详见: [gitlink-release/SKILL.md](gitlink-release/SKILL.md) +详见: [gitlink-release/examples/release-workflow.md](gitlink-release/examples/release-workflow.md) ### 场景 4:搜索和发现 @@ -218,7 +259,7 @@ gitlink-cli org +list gitlink-cli org +info -i Gitlink ``` -详见: [gitlink-search/SKILL.md](gitlink-search/SKILL.md) +详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md) --- @@ -227,8 +268,8 @@ gitlink-cli org +info -i Gitlink ### 快速查找 - **我想了解认证**: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) -- **我想查看 API 细节**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md) -- **我遇到了错误**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md) +- **我想查看 API 细节**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md) +- **我遇到了错误**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md) - **我想看工作流示例**: 查看各 Skill 下的 `examples/` 目录 ### 按功能分类 @@ -236,12 +277,12 @@ gitlink-cli org +info -i Gitlink **仓库操作**: - [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 仓库命令 - [gitlink-branch/SKILL.md](gitlink-branch/SKILL.md) - 分支命令 -- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 完整工作流 +- [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md) - 完整工作流 **Issue 和 PR**: - [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 命令 - [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令 -- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 工作流 +- [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) - Issue 工作流 **发布和搜索**: - [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令 @@ -283,11 +324,11 @@ gitlink-cli auth login ### Q: 如何查看完整的 API 参考? -A: 查看 [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md) +A: 查看 [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md) ### Q: 遇到错误怎么办? -A: 查看 [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md) +A: 查看 [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md) --- @@ -323,7 +364,7 @@ AI 代理可以: - 所有边界情况处理正确 - 完整的文档和示例 -详见: [../doc/design.md](../doc/design.md) +详见: [../doc/SKILLS_TEST_REPORT_2026-04-02.md](../doc/SKILLS_TEST_REPORT_2026-04-02.md) --- @@ -331,7 +372,8 @@ AI 代理可以: - [主项目 README](../README.md) - gitlink-cli 项目说明 - [设计文档](../doc/design.md) - 架构设计和开发计划 -- [API 参考文档](../doc/gitlink_api_reference.md) - GitLink API 参考文档 +- [测试报告](../doc/SKILLS_TEST_REPORT_2026-04-02.md) - 功能测试报告 +- [代码同步方案](../doc/CODE_SYNC_STRATEGY_FINAL.md) - GitHub ↔ GitLink 同步设计 - [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - 代码双向同步系统 --- @@ -339,8 +381,8 @@ AI 代理可以: ## 📞 获取帮助 - **命令帮助**: `gitlink-cli --help` -- **故障排查**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md) -- **API 参考**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md) +- **故障排查**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md) +- **API 参考**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md) - **工作流示例**: 查看各 Skill 下的 `examples/` 目录 --- @@ -348,7 +390,7 @@ AI 代理可以: ## 🎓 下一步 1. 阅读 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 了解基础 -2. 查看 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 完成认证 +2. 查看 [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md) 完成认证 3. 根据需求选择相应的 Skill 文档 4. 参考 `examples/` 目录中的工作流示例 5. 使用 AI 代理自动化你的工作流 diff --git a/skills/gitlink-auth/SKILL.md b/skills/gitlink-auth/SKILL.md new file mode 100644 index 0000000..667c7d2 --- /dev/null +++ b/skills/gitlink-auth/SKILL.md @@ -0,0 +1,81 @@ +--- +name: gitlink-auth +version: 1.0.0 +description: "认证管理:登录、查看登录状态、管理 Token、退出登录。当用户首次使用 gitlink-cli、遇到 401 认证错误、Token 过期、需要登录或退出时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli auth --help" +--- + +# gitlink-auth(认证操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证原理、Token 存储位置、认证错误处理(401/403)和全局参数。** +**CRITICAL — 认证 Token 属于敏感信息,禁止明文输出到终端或日志。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置条件:** 本 Skill 聚焦于 `gitlink-cli auth` 命令的具体操作;认证的全局规则、错误处理、安全约定见 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。 + +## Shortcuts + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `auth login` | 交互式登录(用户名 + 密码) | 否(登录本身) | +| `auth login --token` | 使用已有 Token 登录 | 否 | +| `auth status` | 查看当前登录状态与 Token 有效期 | 否 | +| `auth logout` | 退出登录并清除本地凭证 | 否 | + +## 使用示例 + +```bash +# 方式 1:交互式登录(推荐,按提示输入用户名和密码) +gitlink-cli auth login + +# 方式 2:粘贴已有 Token 登录(适合已有 GitLink 个人访问令牌的场景) +gitlink-cli auth login --token + +# 查看登录状态(确认身份、Token 剩余有效期、存储位置) +gitlink-cli auth status + +# 退出登录,清除本地凭证 +gitlink-cli auth logout +``` + +## 工作流 + +### 工作流 1:首次使用认证 + +1. 运行 `gitlink-cli auth login` 完成交互式登录 +2. 运行 `gitlink-cli auth status` 确认登录成功 +3. 验证可调用受保护接口:`gitlink-cli api GET /users/me --format json` + +### 工作流 2:Token 过期恢复 + +遇到 `401 请登录后再操作` 错误时: + +1. `gitlink-cli auth status` 确认是否过期 +2. 若过期,重新执行 `gitlink-cli auth login` +3. 非交互环境(CI/脚本)改用环境变量:`export GITLINK_TOKEN="your-token"` + +## 决策规则 + +| 条件 | 操作 | +|------|------| +| 用户首次使用 / 未登录 | 引导 `auth login` | +| 报错 `401` | Token 失效或过期,重新 `auth login` | +| 报错 `403` | 已登录但无权限,确认 owner/repo 正确性,非认证问题 | +| CI / 脚本等非交互环境 | 使用 `GITLINK_TOKEN` 环境变量,避免交互式登录 | +| 切换账号 | 先 `auth logout` 再 `auth login` | + +## 注意事项 + +- GitLink Token 有效期 **7 天**,过期需重新登录或刷新 Token +- Token 存储在系统密钥管理器(macOS Keychain / Linux Secret Service / Windows Credential Manager),Fallback 为 `~/.config/gitlink-cli/credentials` +- **禁止** 将 Token 明文输出、打印或写入版本控制的文件 +- 非交互环境优先使用 `GITLINK_TOKEN` 环境变量,而非交互式 `auth login` +- `auth logout` 会清除本地凭证,下次操作前需重新登录 + +## References + +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证原理、Token 说明、401/403 错误处理、全局参数、安全规则 +- [examples/auth-workflow.md](examples/auth-workflow.md) — 认证完整工作流示例 diff --git a/skills/gitlink-auth/examples/auth-workflow.md b/skills/gitlink-auth/examples/auth-workflow.md new file mode 100644 index 0000000..c9c9ed0 --- /dev/null +++ b/skills/gitlink-auth/examples/auth-workflow.md @@ -0,0 +1,48 @@ +# 认证工作流示例 + +**场景**:首次使用 gitlink-cli 的用户需要完成登录认证。 + +## 工作流步骤 + +### Step 1:交互式登录 + +```bash +# 方式 1:用户名密码登录 +gitlink-cli auth login +# 按提示输入用户名和密码 + +# 方式 2:使用已有 Token +gitlink-cli auth login --token +# 粘贴 GitLink 个人访问令牌 +``` + +### Step 2:验证登录状态 + +```bash +gitlink-cli auth status +``` + +**输出示例:** +``` +已登录为: zhangsan +Token 有效期至: 2026-06-19 +存储位置: OS Keychain +``` + +### Step 3:Token 过期后重新登录 + +```bash +# 遇到 401 错误时,重新登录 +gitlink-cli auth login + +# 退出登录 +gitlink-cli auth logout +``` + +--- + +## 注意事项 + +- GitLink Token 有效期 7 天,过期需重新登录 +- Token 存储在系统密钥管理器中,安全可靠 +- 非交互环境可设置环境变量 `GITLINK_TOKEN` diff --git a/skills/gitlink-ci-health/examples/ci-health-workflow.md b/skills/gitlink-ci-health/examples/ci-health-workflow.md new file mode 100644 index 0000000..7c09171 --- /dev/null +++ b/skills/gitlink-ci-health/examples/ci-health-workflow.md @@ -0,0 +1,30 @@ +# gitlink-ci-health · 端到端示例 + +## 场景 +巡检仓库 CI/CD 授权与构建成功率,生成 CI 健康度报告。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli repo +info --owner --repo --format json +gitlink-cli ci +builds --owner --repo --format json +gitlink-cli ci +logs --owner --repo --build --format json +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli repo +info` | `gitlink-cli ci +builds` | `gitlink-cli ci +logs` + diff --git a/skills/gitlink-ci/SKILL.md b/skills/gitlink-ci/SKILL.md index 016daaa..e335526 100644 --- a/skills/gitlink-ci/SKILL.md +++ b/skills/gitlink-ci/SKILL.md @@ -24,6 +24,9 @@ metadata: | `ci +logs` | 构建日志 | 是 | | `ci +restart` | 重启构建 | 是 | | `ci +stop` | 停止构建 | 是 | +| `ci +enable` | 启用 CI | 是 | +| `ci +disable` | 停用 CI | 是 | +| `ci +authorize` | CI 授权状态 | 是 | ## 使用示例 @@ -39,17 +42,18 @@ gitlink-cli ci +restart --build 42 # 停止构建 gitlink-cli ci +stop --build 42 -``` -## Raw API 补充 - -```bash -# 激活 CI -gitlink-cli api POST /:owner/:repo/activate +# 启用 CI(需先配置 .gitlink-ci.yml 流水线文件) +gitlink-cli ci +enable --owner myuser --repo myrepo # 停用 CI -gitlink-cli api DELETE /:owner/:repo/deactivate +gitlink-cli ci +disable --owner myuser --repo myrepo -# CI 授权状态 -gitlink-cli api GET /:owner/:repo/ci_authorize +# 查看 CI 授权状态 +gitlink-cli ci +authorize --owner myuser --repo myrepo ``` + +## 注意事项 + +- `ci +enable` 需要仓库已配置 `.gitlink-ci.yml` 流水线文件,否则返回 -1 +- 构建操作需要仓库已启用 CI,可通过 `ci +authorize` 查看状态 diff --git a/skills/gitlink-ci/examples/ci-workflow.md b/skills/gitlink-ci/examples/ci-workflow.md new file mode 100644 index 0000000..14e0335 --- /dev/null +++ b/skills/gitlink-ci/examples/ci-workflow.md @@ -0,0 +1,63 @@ +# CI 构建管理完整工作流示例 + +**场景**:开发者需要查看 CI 构建状态、重启失败的构建。 + +## 工作流步骤 + +### Step 1:查看构建列表 + +```bash +# 列出最近的构建 +gitlink-ci build list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { + "build_number": 156, + "status": "success", + "branch": "master", + "trigger": "push", + "duration": "3m 42s", + "created_at": "2026-05-28T15:30:00+08:00" + }, + { + "build_number": 155, + "status": "failure", + "branch": "feature/new-api", + "trigger": "push", + "duration": "5m 10s" + } + ] +} +``` + +### Step 2:查看构建详情 + +```bash +# 查看特定构建的详细信息 +gitlink-cli ci +builds --owner myorg --repo myproject --format json + +# 查看构建日志(失败构建) +gitlink-cli ci +log --build 155 +``` + +### Step 3:重启构建 + +```bash +# 重启失败的构建 +gitlink-cli ci +restart --build 155 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli ci +builds --owner --repo --format json +gitlink-cli ci +log --build +gitlink-cli ci +restart --build +``` diff --git a/skills/gitlink-collab-match/SKILL.md b/skills/gitlink-collab-match/SKILL.md new file mode 100644 index 0000000..3342307 --- /dev/null +++ b/skills/gitlink-collab-match/SKILL.md @@ -0,0 +1,76 @@ +--- +name: gitlink-collab-match +version: 1.0.0 +description: "科研协作智能匹配(子赛题四·S4):分析科研仓库的技术缺口(未解决 Issue 主题/语言、开放 PR、研究空缺),结合候选人科研画像,智能匹配跨团队/跨学者协作伙伴。当用户要找协作者、推荐合作者、分析仓库需要什么样的人时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + python: ["scripts/research/requirements.txt"] + cliHelp: "gitlink-cli research +match --help" + scenario: "S4" +--- + +# gitlink-collab-match — 科研协作智能匹配 + +> 子赛题四「应用 GitLink 辅助科研」· 场景 **S4 科研协作智能匹配** + +## 何时使用 + +- 课题组/科研团队想为一个科研代码仓库寻找合适的协作伙伴(跨团队/跨学者)。 +- 想知道「这个仓库当前最缺哪方面的人/技能」。 +- 为开源科研项目做人员招募建议、互补团队推荐。 + +## 前置条件 + +1. 已 `gitlink-cli auth login`(Token 7 天有效)。 +2. 已 `pip install -r scripts/research/requirements.txt`(本场景实际只用标准库 + topics 词典,无需重型依赖)。 +3. 目标仓库存在且有若干未解决 Issue(缺口信号来源)。 + +## 工作流 + +本 Skill 的算法由 `scripts/research/match.py` 实现(Go 出数据 + Python 做匹配): + +1. **缺口分析**:调 `issue +list --state open`(按优先级加权)+ `pr +list --state open` + `repo +languages` + README,用 `topics.py` 词典抽取出仓库的**缺口主题向量**与**需求语言**。 +2. **候选池**:本仓库贡献者(`repo +contributors`,过滤 bot)+ 按缺口主题用 `search +users` 搜到的外部用户,上限默认 15。 +3. **候选人画像**:对每个候选人调 `repo +list --user `,聚合其公开仓库的主题向量、语言集合、fork 数(协作开放度)、活跃度。 +4. **综合打分**: + `score = 0.45×主题重叠(余弦) + 0.20×语言匹配(Jaccard) + 0.20×活跃度 + 0.15×协作开放度`(×100)。 +5. **产物**:`match.json`(结构化)+ `report.md`(中文推荐报告,含缺口表 + 排名表 + 理由)+ `network.mmd`(Mermaid 协作网络图)。 + +## 命令 + +```bash +# 默认输出到 stdout(JSON) +python scripts/research/match.py --owner mindspore-Ecosystem --repo mindspore + +# 输出三件产物到目录 +python scripts/research/match.py --owner --repo --top 10 --pool 15 --out ./out + +# 可复现脚本(封装了上述流程) +bash skills/gitlink-collab-match/examples/collab-match-workflow.sh [OUT_DIR] +``` + +## 输出结构(match.json) + +```json +{ + "scenario": "S4_collaboration_matching", + "repo": "owner/repo", + "gap_topics": ["deep_learning", "computer_vision", "..."], + "needed_languages": ["python", "..."], + "gap_signals": [{"type":"unresolved_issue","topic":"deep_learning","evidence":"...","priority":"高"}], + "candidates": [{"login":"...","score":17.0,"topic_overlap":0.31,"language_match":0.5, + "activity_level":"high","repo_languages":["python"],"reasons":["覆盖缺口主题: ..."]}] +} +``` + +## 验证 + +已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(20346 条 issue)上验证: +缺口主题正确识别为 deep_learning / scientific_computing / RL / CV 等; +Top 推荐为仓库真实活跃贡献者(yefeng / He_Wei / gaoyong10)。 + +## 兼容性 + +兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据, +Agent 可直接调上述命令并把产物读回做进一步解读与文案化。 diff --git a/skills/gitlink-collab-match/examples/collab-match-workflow.md b/skills/gitlink-collab-match/examples/collab-match-workflow.md new file mode 100644 index 0000000..ff88669 --- /dev/null +++ b/skills/gitlink-collab-match/examples/collab-match-workflow.md @@ -0,0 +1,28 @@ +# gitlink-collab-match · 端到端示例 + +## 场景 +分析科研仓库技术缺口 + 候选人画像,智能匹配协作伙伴(S4)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli research +match --help" +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli research +match --help"` + diff --git a/skills/gitlink-collab-match/examples/collab-match-workflow.sh b/skills/gitlink-collab-match/examples/collab-match-workflow.sh new file mode 100644 index 0000000..e713a88 --- /dev/null +++ b/skills/gitlink-collab-match/examples/collab-match-workflow.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# collab-match-workflow.sh — S4 科研协作智能匹配 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一 +# +# 用法: bash collab-match-workflow.sh [OUT_DIR] [POOL] [TOP] [ISSUE_SAMPLE] +# 示例: bash collab-match-workflow.sh mindspore-Ecosystem mindspore ./out 15 10 100 +set -euo pipefail + +OWNER="${1:?用法: $0 [OUT_DIR] [POOL] [TOP] [ISSUE_SAMPLE]}" +REPO="${2:?缺少 REPO}" +OUT_DIR="${3:-./collab-match-output}" +POOL="${4:-15}" +TOP="${5:-10}" +ISSUE_SAMPLE="${6:-100}" + +# 定位仓库根(脚本位于 skills/gitlink-collab-match/examples/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +MATCH="$REPO_ROOT/scripts/research/match.py" + +# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值 +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 目标仓库: $OWNER/$REPO" +echo "==> CLI: $GITLINK_CLI" +echo "==> 候选池=$POOL Top=$TOP Issue采样=$ISSUE_SAMPLE" + +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$MATCH" \ + --owner "$OWNER" --repo "$REPO" \ + --pool "$POOL" --top "$TOP" --issue-sample "$ISSUE_SAMPLE" \ + --out "$OUT_DIR" + +echo +echo "==> 产物:" +ls -1 "$OUT_DIR" +echo +echo "==> Top 推荐预览:" +python -c " +import json,sys +d=json.load(open('$OUT_DIR/match.json',encoding='utf-8')) +print('缺口主题:', ', '.join(d['gap_topics'])) +for i,m in enumerate(d['candidates'],1): + print(f\" {i}. {m['login']} ({m['score']}分) — {'; '.join(m['reasons'][:2])}\") +" diff --git a/skills/gitlink-commit-quality/examples/commit-quality-workflow.md b/skills/gitlink-commit-quality/examples/commit-quality-workflow.md new file mode 100644 index 0000000..0afd796 --- /dev/null +++ b/skills/gitlink-commit-quality/examples/commit-quality-workflow.md @@ -0,0 +1,47 @@ +# 提交质量检查完整工作流示例 + +**场景**:项目维护者需要检查提交信息的质量和规范性。 + +## 工作流步骤 + +### Step 1:获取提交历史 + +```bash +# 获取最近的提交记录 +gitlink-cli repo +commits --owner myorg --repo myproject --limit 20 --format json +``` + +### Step 2:分析提交信息 + +AI 检查提交信息是否符合 Conventional Commits 规范: + +```markdown +## 📝 提交质量报告 + +### 检查项 + +| 检查项 | 标准 | 结果 | +|--------|------|------| +| 格式规范 | Conventional Commits | 15/20 通过 | +| 描述清晰 | 有具体说明 | 18/20 通过 | +| 关联 Issue | 引用 Issue 编号 | 12/20 通过 | + +### 不合规示例 + +1. `fix bug` → 应改为 `fix: resolve login timeout issue (#165)` +2. `update` → 应改为 `docs: update API reference` +3. `wip` → WIP 提交不应出现在主分支 + +### 建议 +- 遵循 `type(scope): description` 格式 +- type 可选:feat / fix / docs / style / refactor / perf / test / ci / chore +- 添加 Breaking Change 标注(如有) +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli repo +commits --owner --repo --limit --format json +``` diff --git a/skills/gitlink-compare/examples/compare-workflow.md b/skills/gitlink-compare/examples/compare-workflow.md new file mode 100644 index 0000000..5f68b64 --- /dev/null +++ b/skills/gitlink-compare/examples/compare-workflow.md @@ -0,0 +1,38 @@ +# 代码比较完整工作流示例 + +**场景**:开发者需要比较两个分支或提交之间的差异。 + +## 工作流步骤 + +### Step 1:比较两个分支 + +```bash +# 比较两个分支的差异 +gitlink-cli api GET /:owner/:repo/compare/master...develop --format json +``` + +### Step 2:查看提交差异 + +```bash +# 获取两个提交之间的差异 +gitlink-cli api GET /:owner/:repo/compare/abc123...def456 --format json +``` + +### Step 3:查看特定文件变更 + +```bash +# 获取文件的变更历史 +gitlink-cli repo +commits --owner myorg --repo myproject --format json + +# 获取某个提交的详细内容 +gitlink-cli api GET /:owner/:repo/commits/ --format json +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli api GET /:owner/:repo/compare/... --format json +gitlink-cli repo +commits --owner --repo --format json +``` diff --git a/skills/gitlink-competition-manager/examples/competition-manager-workflow.md b/skills/gitlink-competition-manager/examples/competition-manager-workflow.md new file mode 100644 index 0000000..b4f9136 --- /dev/null +++ b/skills/gitlink-competition-manager/examples/competition-manager-workflow.md @@ -0,0 +1,33 @@ +# gitlink-competition-manager · 端到端示例 + +## 场景 +批量创建编程竞赛队伍仓库、初始化题目与权限。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli user +info --login --format json +gitlink-cli issue +create +gitlink-cli issue +list --state open --owner --repo --format json +gitlink-cli pr +list --state open --owner --repo --format json +gitlink-cli pr +list --state merged --owner --repo --format json +gitlink-cli pr +view --id --owner --repo --format json +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli user +info --login --format json` | `gitlink-cli issue +create` | `gitlink-cli issue +list --state open` | `gitlink-cli pr +list --state open` + diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md index bee901a..18d0633 100644 --- a/skills/gitlink-compliance/SKILL.md +++ b/skills/gitlink-compliance/SKILL.md @@ -6,20 +6,38 @@ metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli repo --help" + scenario: "S3" --- -# gitlink-compliance(开源合规检查) +# gitlink-compliance(开源合规与复现性检查) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 +子赛题四「应用 GitLink 辅助科研」· 场景 **S3 科研项目合规与复现性检查** 的自动化算法由 +`scripts/research/repro.py`(Go 出数据 + Python 做算法)实现,见 **工作流 4**。 + +--- + +## 何时使用 + +- 科研项目准备开源发布前,做一次全面的合规与复现性审查。 +- 想知道「这个仓库别人能不能复现」:CI、lockfile、README 复现说明、版本 tag、容器环境是否齐备。 +- 想发现仓库里的合规风险与敏感信息泄露:缺 LICENSE / 版权头、数据目录入库、`.env` 泄露、硬编码密钥。 + +## 前置条件 + +1. 已 `gitlink-cli auth login`(Token 7 天有效)。 +2. 目标仓库存在且可读取文件树(`repo +info` / `file +get` / `repo +tree`)。 +3. 复现性自动化检查(工作流 4)只用 Python 标准库,无需第三方依赖。 + --- ## 工作流概览 -本 Skill 提供开源项目的合规性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。 +本 Skill 提供开源项目的合规性与复现性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。 | 检查类型 | 覆盖范围 | 严重程度 | |----------|---------|:--------:| @@ -28,6 +46,8 @@ metadata: | 依赖合规 | 第三方依赖许可证兼容性 | 🔴 | | 安全策略 | SECURITY.md、安全披露流程 | 🟡 | | 贡献者协议 | CLA / DCO 要求 | 🔵 | +| 复现性 | CI / lockfile / README 复现说明 / 版本 tag / 容器环境 | 🟡 | +| 数据隐私 | data/ 入库、.env 泄露、密钥硬编码 | 🔴 | --- @@ -206,6 +226,78 @@ gitlink-cli api GET /:owner/:repo/raw/master/src/main.py --- +## 工作流 4:合规与复现性自动化检查(repro.py) + +**场景**:子赛题四·S3 科研项目合规与复现性检查 —— 对一个科研仓库同时给出「合规分」与「复现分」,并产出检查清单、风险项与中文报告。 + +本工作流的算法由 `scripts/research/repro.py` 实现,数据全部经 gitlink-cli 获取(Go 出数据 + Python 做算法)。 + +### 数据采集 + +`repro.py` 内部调用以下 gitlink-cli 命令(已封装在 `collect.py` 中): + +```bash +# 仓库信息(默认分支、版本 tag) +gitlink-cli --owner --repo repo +info --format json + +# 关键文件文本(LICENSE / README / go.mod / requirements.txt / package.json / .gitignore / SECURITY.md / ...) +gitlink-cli --owner --repo file +get --path LICENSE --ref master + +# 根文件树(扫 data/、.env、config、.gitea/.github workflows 等是否存在) +gitlink-cli --owner --repo repo +tree --ref master + +# 语言占比(仅作为元信息记录) +gitlink-cli --owner --repo repo +languages --format json +``` + +### 算法(纯函数,可单测) + +| 函数 | 作用 | +|------|------| +| `identify_license(text)` | 关键词匹配 MulanPSL / Apache / MIT / GPL / LGPL / BSD / ISC / MPL / 无 | +| `scan_secrets(text, file)` | 正则找 private key / AWS token / API key / Slack / GitHub token / JWT / 邮箱 / 手机号 → `[{level,category,file,line,detail}]`(脱敏) | +| `repro_checks(file_texts, tree, repo_info)` | CI 配置、lockfile、README 复现说明、版本 tag、容器化,每项 `{name,pass,score(0-2),evidence}` | +| `compliance_items(license_info, file_texts, tree)` | LICENSE 声明、SECURITY.md、版权头、依赖合规、CONTRIBUTING.md | +| `data_privacy(tree, gitignore_text)` | data/ 入库、.env 入库、.gitignore 是否忽略 .env | + +打分:`repro_score` / `compliance_score` 均为 0-10(各项 0-2 分聚合归一)。 + +### 命令 + +```bash +# 默认输出到 stdout(JSON) +python scripts/research/repro.py --owner mindspore-Ecosystem --repo mindspore + +# 输出两件产物到目录(repro.json + compliance_report.md) +python scripts/research/repro.py --owner --repo --out ./out + +# 可复现脚本(封装了上述流程) +bash skills/gitlink-compliance/examples/compliance-repro-workflow.sh [OUT_DIR] +``` + +### 输出结构(repro.json) + +```json +{ + "scenario": "S3_compliance_reproducibility", + "repo": "owner/repo", + "default_branch": "master", + "license": "MIT", + "repro_items": [{"name": "CI 配置", "pass": true, "score": 2, "evidence": "..."}], + "compliance_items": [{"name": "LICENSE 文件", "pass": true, "score": 2, "evidence": "..."}], + "privacy_items": [{"name": ".env 入库", "pass": true, "score": 2, "evidence": "..."}], + "secrets": [{"level": "critical", "category": "private_key", "file": "config.env", "line": 5, "detail": "..."}], + "risks": [{"area": "secret", "name": "private_key", "file": "...", "level": "critical", "evidence": "..."}], + "repro_score": 8.0, + "compliance_score": 6.0, + "meta": {"key_files_found": ["LICENSE", "README.md"], "tree_size": 42, "languages": {"Python": "99%"}} +} +``` + +`compliance_report.md` 包含:复现性检查清单表、合规性检查清单表、数据隐私检查表、风险项表(按严重程度排序)与打分。 + +--- + ## Raw API 参考 ```bash diff --git a/skills/gitlink-compliance/examples/compliance-repro-workflow.sh b/skills/gitlink-compliance/examples/compliance-repro-workflow.sh new file mode 100644 index 0000000..b4f07cf --- /dev/null +++ b/skills/gitlink-compliance/examples/compliance-repro-workflow.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# compliance-repro-workflow.sh — S3 科研项目合规与复现性检查 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一 +# +# 用法: bash compliance-repro-workflow.sh [OUT_DIR] +# 示例: bash compliance-repro-workflow.sh mindspore-Ecosystem mindspore ./out +set -euo pipefail + +OWNER="${1:?用法: $0 [OUT_DIR]}" +REPO="${2:?缺少 REPO}" +OUT_DIR="${3:-./compliance-repro-output}" + +# 定位仓库根(脚本位于 skills/gitlink-compliance/examples/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +REPRO="$REPO_ROOT/scripts/research/repro.py" + +# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值 +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 目标仓库: $OWNER/$REPO" +echo "==> CLI: $GITLINK_CLI" +echo "==> 输出目录: $OUT_DIR" + +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$REPRO" \ + --owner "$OWNER" --repo "$REPO" \ + --out "$OUT_DIR" + +echo +echo "==> 产物:" +ls -1 "$OUT_DIR" +echo +echo "==> 合规/复现检查摘要:" +python -c " +import json +d=json.load(open('$OUT_DIR/repro.json',encoding='utf-8')) +print('许可证:', d['license']) +print('复现分: %s/10' % d['repro_score']) +print('合规分: %s/10' % d['compliance_score']) +print('风险项: %d 处' % len(d['risks'])) +for r in d['risks'][:5]: + loc = (r.get('file','') + ':' + str(r.get('line',''))) if r.get('file') else '-' + print(' [%s] %s (%s) %s' % (r.get('level','medium'), r.get('name',''), loc, r.get('evidence') or r.get('detail',''))) +" diff --git a/skills/gitlink-compliance/examples/compliance-workflow.md b/skills/gitlink-compliance/examples/compliance-workflow.md new file mode 100644 index 0000000..f6fa9a7 --- /dev/null +++ b/skills/gitlink-compliance/examples/compliance-workflow.md @@ -0,0 +1,52 @@ +# 合规检查完整工作流示例 + +**场景**:项目维护者需要检查项目的合规性(许可证、代码规范等)。 + +## 工作流步骤 + +### Step 1:检查项目许可证 + +```bash +# 查看 LICENSE 文件 +gitlink-cli repo +raw --owner myorg --repo myproject --path LICENSE + +# 查看仓库信息中的许可证 +gitlink-cli repo +info --owner myorg --repo myproject --format json +``` + +### Step 2:检查项目结构 + +```bash +# 查看根目录文件(寻找 .gitignore, CONTRIBUTING.md 等) +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=.&ref=master' + +# 检查 CI 配置 +gitlink-cli repo +raw --owner myorg --repo myproject --path .gitea/workflows/ci.yml +``` + +### Step 3:生成合规报告 + +AI 根据采集到的信息生成合规报告: + +```markdown +## 📋 项目合规检查报告 + +| 检查项 | 状态 | 说明 | +|--------|:----:|------| +| LICENSE 文件 | ✅ | Apache-2.0 | +| README.md | ✅ | 完整 | +| CONTRIBUTING.md | ⚠️ | 缺失,建议补充 | +| .gitignore | ✅ | 已配置 | +| CI 配置 | ✅ | Gitea Actions | +| 代码规范配置 | ⚠️ | 缺少 linter 配置 | +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli repo +raw --owner --repo --path LICENSE +gitlink-cli repo +info --owner --repo --format json +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=.&ref=master' +``` diff --git a/skills/gitlink-digest/SKILL.md b/skills/gitlink-digest/SKILL.md new file mode 100644 index 0000000..497dcda --- /dev/null +++ b/skills/gitlink-digest/SKILL.md @@ -0,0 +1,159 @@ +--- +name: gitlink-digest +version: 1.0.0 +description: "每日简报:聚合仓库 Issue/PR/CI/通知动态,生成一份可读的项目简报。当用户提到「每日简报」「今天发生了什么」「项目动态」「日报」「digest」「简报」「汇总」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli --help" +--- + +# gitlink-digest(每日简报) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**CRITICAL — 本 Skill 只读聚合,不产生任何写操作,安全可随时运行。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 功能定位 + +解决「信息太分散」的体验痛点:用户不用挨个刷 Issue/PR/CI/通知,AI 一次性采集多源数据,聚合分类,输出**一份按优先级排序的 Markdown 简报**。 + +| 阶段 | 操作 | AI Agent 角色 | +|------|------|--------------| +| ① 采集 | 并行拉取 Issue/PR/CI/通知/活跃度 | 执行 CLI 命令采集多源数据 | +| ② 分类 | 按主题归类(新增/动态/CI/需关注) | 去重、排序、标注优先级 | +| ③ 聚合 | 合并成一份简报 | 生成结构化 Markdown | +| ④ 输出 | 可选发布到 Wiki/Issue | 生成或发布简报 | + +### 与 `gitlink-notification-digest` 的分工(避免重复) + +项目已有 `gitlink-notification-digest`,专注**通知/消息中心**(messages API、按 source 分类、标记已读)。本 Skill 定位不同——做**项目全景日报**: + +| 维度 | notification-digest(团队已有) | digest(本 Skill) | +|------|-------------------------------|-------------------| +| 范围 | 通知/消息(messages) | 项目全景:Issue + PR + CI + 活跃度 + 通知 | +| 重点 | 通知分类、标记已读、清理未读 | 跨源聚合、按优先级出日报 | +| 输出 | 通知摘要(P0–P3) | 项目简报(需关注/新增/进行中/指标) | + +> 本 Skill **不做**通知标记已读(那是 notification-digest 的职责),只把通知作为简报的一个输入源。 + +## 数据源(均为只读) + +| 数据 | 命令 | 说明 | +|------|------|------| +| 近期 Issue | `gitlink-cli issue +list --state open --format json` | 新增/开放 Issue | +| 近期 PR | `gitlink-cli pr +list --state open --format json` | PR 动态 | +| CI 构建 | `gitlink-cli ci +builds --owner --repo --format json` | 构建成功/失败 | +| 通知消息 | `gitlink-cli api GET "users/{owner}/messages.json"` | @我/系统通知 | +| 活跃度 | `gitlink-cli api GET "users/{owner}/statistics/activity.json"` | 贡献活跃度 | + +> 注:通知/活跃度为 Raw API,路径以 API 文档为准;首次使用建议带 `--debug` 确认 CLI 的路径前缀行为。 + +## 使用示例 + +```bash +# 1. 采集近期 Issue(默认从 git remote 解析 owner/repo) +gitlink-cli issue +list --state open --format json + +# 2. 采集近期 PR +gitlink-cli pr +list --state open --format json + +# 3. 采集 CI 构建状态 +gitlink-cli ci +builds --owner --repo --format json + +# 4. 采集通知消息(Raw API) +gitlink-cli api GET /api/users//messages.json + +# 5. 采集活跃度统计(Raw API) +gitlink-cli api GET /api/users//statistics/activity.json +``` + +## 工作流 + +### 工作流 1:生成每日简报(核心) + +**场景**:用户问「今天我的项目发生了什么 / 给我一份简报」。 + +#### Step 1:并行采集多源数据 + +```bash +gitlink-cli issue +list --state open --format json +gitlink-cli pr +list --state open --format json +gitlink-cli ci +builds --owner --repo --format json +gitlink-cli api GET /api/users//messages.json +gitlink-cli api GET /api/users//statistics/activity.json +``` + +#### Step 2:AI 分类聚合 + +对采集到的数据按以下维度归类: +- **🔴 需立即关注**:失败的 CI、@我的紧急消息、阻塞型 PR +- **🟢 新增动态**:新开的 Issue、新提交的 PR +- **🔵 进行中**:有更新的 Issue/PR、待 review 的 PR +- **📊 健康指标**:活跃度数字、Issue/PR 增减趋势 + +#### Step 3:输出简报 + +按下方「输出模板」生成 Markdown。 + +### 工作流 2:发布简报 + +**场景**:把简报发布为 Wiki 或 Issue 评论(写操作,需确认)。 + +```bash +# 发布为 Wiki 页面(⚠️ 写操作,需确认) +gitlink-cli wiki +create --name "Daily-" --content "<简报内容>" + +# 或发布为 Issue 评论 +gitlink-cli issue +comment --number --body "<简报内容>" +``` + +## 决策规则 + +| 条件 | 处理 | +|------|------| +| 数据量大(Issue/PR > 50) | 只取最近 24h 或 top 20,其余汇总计数 | +| CI 有失败 | 置顶到「需立即关注」,附 build 号 | +| 有 @我 的消息 | 置顶,标注来源 Issue/PR | +| 采集某数据源失败(403/404) | 跳过该源,简报中标注「⚠️ XX 数据未获取」 | +| 简报需对外发布 | 写操作,必须先确认用户意图 | + +## 输出模板 + +```markdown +# 📰 项目简报 — /) + +## 🔴 需立即关注 +1. ❌ CI 构建 # 失败(分支 master)— <错误摘要> +2. 🔔 @你 在 Issue #:<消息摘要> + +## 🟢 今日新增 +- **新 Issue**: 个,其中 bug / enhancement +- **新 PR**: 个 + +## 🔵 进行中 +- 待 Review 的 PR:#、# +- 有更新的 Issue:#、# + +## 📊 健康指标 +- 开放 Issue:(较昨日 +) +- 开放 PR: +- 近期活跃度: + +--- +*由 gitlink-digest Skill 于 <时间> 生成* +``` + +## 注意事项 + +- 本 Skill **纯只读聚合**,不修改任何资源,可放心运行 +- 多源数据采集建议用 `--format json` 便于 AI 解析 +- 通知/活跃度是 Raw API,路径前缀以实际 CLI 行为准(首次 `--debug` 验证) +- 时间范围默认「近期」,可由用户指定(如「本周」「最近 3 天」) +- 简报对外发布(Wiki/Issue 评论)属写操作,必须先确认 + +## References + +- 全局参数与安全规则:[gitlink-shared/SKILL.md](../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-digest/examples/digest-workflow.md b/skills/gitlink-digest/examples/digest-workflow.md new file mode 100644 index 0000000..86591e5 --- /dev/null +++ b/skills/gitlink-digest/examples/digest-workflow.md @@ -0,0 +1,84 @@ +# 每日简报完整工作流示例 + +**场景**:项目负责人早上想快速了解「昨天项目发生了什么」,AI 采集多源数据生成一份简报。 + +## 前置条件 + +- `gitlink-cli` 已登录 +- 在目标仓库目录下(自动解析 owner/repo),或手动传 `--owner/--repo` + +## 工作流步骤 + +### Step 1:并行采集多源数据 + +```bash +# 近期 Issue +gitlink-cli issue +list --state open --format json + +# 近期 PR +gitlink-cli pr +list --state open --format json + +# CI 构建状态 +gitlink-cli ci +builds --owner myorg --repo myproject --format json + +# 通知消息(Raw API) +gitlink-cli api GET "users/zhangsan/messages.json" + +# 活跃度统计(Raw API) +gitlink-cli api GET "users/zhangsan/statistics/activity.json" +``` + +### Step 2:AI 分类聚合 + +输出示例: + +```markdown +# 📰 项目简报 — myorg/myproject(2026-06-23) + +## 🔴 需立即关注 +1. ❌ CI 构建 #156 失败(分支 master)— test_phase 报错:连接超时 +2. 🔔 @你 在 PR #88:「请帮忙看下认证模块的重试逻辑」 + +## 🟢 今日新增 +- **新 Issue**:4 个(bug 2 / enhancement 1 / question 1) + - #201 登录页面 500 错误 + - #202 支持导出 CSV +- **新 PR**:2 个 + - #90 feat: 增加批量导入 + +## 🔵 进行中 +- 待 Review 的 PR:#88、#87 +- 有更新的 Issue:#198、#195 + +## 📊 健康指标 +- 开放 Issue:23(较昨日 +3) +- 开放 PR:5 +- 近期活跃度:94 + +--- +*由 gitlink-digest Skill 于 2026-06-23 09:15 生成* +``` + +### Step 3(可选):发布简报 + +```bash +# 发布为 Wiki 页面(⚠️ 写操作,需确认) +gitlink-cli wiki +create --name "Daily-2026-06-23" --content "<简报内容>" +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli issue +list --state open --format json +gitlink-cli pr +list --state open --format json +gitlink-cli ci +builds --owner --repo --format json +gitlink-cli api GET "users//messages.json" +gitlink-cli api GET "users//statistics/activity.json" +``` + +## 注意事项 + +- 纯只读聚合,安全可随时运行 +- 通知/活跃度为 Raw API,首次使用带 `--debug` 确认路径 diff --git a/skills/gitlink-file/SKILL.md b/skills/gitlink-file/SKILL.md new file mode 100644 index 0000000..e800369 --- /dev/null +++ b/skills/gitlink-file/SKILL.md @@ -0,0 +1,51 @@ +--- +name: gitlink-file +version: 1.0.0 +description: "仓库文件操作:浏览目录、查看文件、创建、更新、删除文件。当用户需要在 GitLink 仓库中操作文件时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli file --help" +--- + +# gitlink-file(文件操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** + +## Shortcuts + +| Shortcut | 说明 | +|----------|------| +| `file +browse` | 浏览目录树或文件详情 | +| `file +get` | 获取文件内容 | +| `file +create` | 创建文件 | +| `file +update` | 更新文件 | +| `file +delete` | 删除文件 | + +## 使用示例 + +```bash +# 浏览目录 +gitlink-cli file +browse --owner Gitlink --repo forgeplus --path src/ + +# 获取文件内容 +gitlink-cli file +get --owner Gitlink --repo forgeplus --path README.md + +# 创建文件(内容自动 base64 编码) +gitlink-cli file +create --owner myuser --repo myrepo --path docs/guide.md --content "# Guide" --message "Add guide" + +# 更新文件(SHA 自动获取) +gitlink-cli file +update --owner myuser --repo myrepo --path docs/guide.md --content "# Updated Guide" + +# 删除文件 +gitlink-cli file +delete --owner myuser --repo myrepo --path old-file.txt +``` + +## API 注意事项 + +- **内容自动 base64 编码**:`file +create` 和 `file +update` 会自动将 content 编码为 base64 +- **SHA 自动获取**:`file +update` 和 `file +delete` 会自动获取文件 SHA,无需手动提供。也可通过 `--sha` 手动指定 +- 文件路径使用 `--path` 参数,API 自动处理 base64 路径编码 +- `file +browse` 和 `file +get` 使用非 v1 路径:`/{owner}/{repo}/sub_entries` +- 写操作使用:`/{owner}/{repo}/create_file`、`/{owner}/{repo}/update_file`、`/{owner}/{repo}/delete_file` diff --git a/skills/gitlink-file/examples/file-workflow.md b/skills/gitlink-file/examples/file-workflow.md new file mode 100644 index 0000000..21c0ad6 --- /dev/null +++ b/skills/gitlink-file/examples/file-workflow.md @@ -0,0 +1,66 @@ +# 文件操作完整工作流示例 + +**场景**:开发者需要通过 API 在仓库中创建、更新或删除文件。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 熟悉 GitLink 文件操作 API(base64 编码、SHA 校验) + +## 工作流步骤 + +### Step 1:创建文件 + +```bash +# content 必须 base64 编码 +# 方式 1:Linux/macOS +CONTENT=$(echo -n "# 项目文档\n\n这是一个示例文档" | base64) + +# 方式 2:直接传递 +gitlink-cli api POST /:owner/:repo/create_file --body '{ + "filepath": "docs/guide.md", + "content": "IyDpobnnm67mlrnlvI8KCuacrOeahOWLvumZpGRvYw==", + "branch": "master", + "message": "docs: add project guide" +}' +``` + +### Step 2:获取文件 SHA(用于更新/删除) + +```bash +# 获取文件信息(含 SHA) +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=docs/guide.md&ref=master' +# 从返回结果中取 entries.sha +``` + +### Step 3:更新文件 + +```bash +gitlink-cli api PUT /:owner/:repo/update_file --body '{ + "filepath": "docs/guide.md", + "content": "", + "sha": "<从sub_entries获取的sha>", + "branch": "master", + "message": "docs: update project guide" +}' +``` + +### Step 4:删除文件 + +```bash +gitlink-cli api DELETE /:owner/:repo/delete_file --body '{ + "filepath": "docs/old-guide.md", + "sha": "<文件sha>", + "branch": "master", + "message": "docs: remove outdated guide" +}' +``` + +--- + +## 注意事项 + +- 创建文件时 content 必须使用 base64 编码 +- 更新和删除文件需要提供文件的 SHA 值 +- SHA 可通过 `sub_entries` 接口获取 +- 文件操作会直接在指定分支上生成一个提交 diff --git a/skills/gitlink-health/examples/health-workflow.md b/skills/gitlink-health/examples/health-workflow.md new file mode 100644 index 0000000..d821a11 --- /dev/null +++ b/skills/gitlink-health/examples/health-workflow.md @@ -0,0 +1,77 @@ +# 项目健康度分析完整工作流示例 + +**场景**:项目维护者需要全面了解项目健康状况,生成健康度报告。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 仓库有一定数量的 Issue 和 PR 历史 + +## 工作流步骤 + +### Step 1:采集仓库数据 + +```bash +# 采集 PR 和 Issue 数据到 SQLite +gitlink-cli health +fetch --owner myorg --repo myproject +``` + +**输出示例:** +``` +正在采集仓库数据... +✓ 获取仓库信息: myorg/myproject +✓ 采集 Issue 数据: 156 条 +✓ 采集 PR 数据: 89 条 +✓ 数据已保存到 ~/.agents/skills/gitlink-health/data/gitlink_health.db +``` + +### Step 2:查询关键指标 + +参考 `references/queries.md` 执行 SQL 查询: + +```bash +# 查询 Issue 平均解决时长 +sqlite3 ~/.agents/skills/gitlink-health/data/gitlink_health.db \ + "SELECT AVG(julianday(closed_at) - julianday(created_at)) as avg_days FROM issues WHERE closed_at IS NOT NULL" + +# 查询 PR 合并率 +sqlite3 ~/.agents/skills/gitlink-health/data/gitlink_health.db \ + "SELECT COUNT(CASE WHEN status='merged' THEN 1 END)*100.0/COUNT(*) as merge_rate FROM pulls" + +# 查询贡献者活跃度 +sqlite3 ~/.agents/skills/gitlink-health/data/gitlink_health.db \ + "SELECT author, COUNT(*) as pr_count FROM pulls GROUP BY author ORDER BY pr_count DESC LIMIT 10" +``` + +### Step 3:生成健康度报告 + +按照 `asset/health_report_template.md` 模板组装报告: + +```markdown +## 🏥 项目健康度报告 — myorg/myproject + +### 总体评分:⭐⭐⭐⭐ (4/5) + +| 维度 | 状态 | 评分 | 建议 | +|------|:----:|:----:|------| +| 📖 文档 | ✅ | ☆☆☆☆☆ | README 完整,有 API 文档 | +| 📜 许可证 | ✅ | ☆☆☆☆☆ | Apache-2.0 | +| 🔧 CI/CD | ✅ | ☆☆☆☆☆ | Gitea Actions 配置完善 | +| 🐛 Issue 管理 | ⚠️ | ☆☆☆☆☆ | 平均解决时长 8.5 天,偏长 | +| 🔀 PR 活跃度 | ✅ | ☆☆☆☆☆ | 合并率 78%,活跃度良好 | +| 👥 贡献者 | ⚠️ | ☆☆☆☆☆ | 核心贡献者 3 人,较集中 | + +### 关键发现 +1. Issue 解决时长偏长,建议引入自动分拣 +2. 贡献者集中度高,需吸引更多外部贡献者 +3. CI 配置完善,构建成功率高 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli health +fetch --owner --repo +gitlink-cli health +fetch --owner --repo --max-pages 5 +``` diff --git a/skills/gitlink-issue-tag/examples/issue-tag-workflow.md b/skills/gitlink-issue-tag/examples/issue-tag-workflow.md new file mode 100644 index 0000000..defdd9d --- /dev/null +++ b/skills/gitlink-issue-tag/examples/issue-tag-workflow.md @@ -0,0 +1,28 @@ +# gitlink-issue-tag · 端到端示例 + +## 场景 +管理 GitLink 项目标记(Issue 标签)的增删改查。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli issue +list --state open --owner --repo --format json +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli issue +list --state open` + diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 9bdb1b3..4933698 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue version: 2.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭/批量更新/批量删除 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" +description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -26,8 +26,8 @@ metadata: | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | -| `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | -| `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | +| `issue +batch-update` | 批量更新 Issue(状态/优先级/里程碑/标签/负责人) | 是 | +| `issue +batch-delete` | 批量删除 Issue(需 `--confirm` 确认) | 是 | | `issue +comment` | 添加评论 | 是 | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | | `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | @@ -62,12 +62,17 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 -- # 从 CSV 文件批量关闭 Issue gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv -# 按 API issue id 预览批量更新元数据(注意不是网页 Issue 编号) -gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --status-id 3 --priority-id 2 --dry-run +# 预览批量更新 Issue +gitlink-cli issue +batch-update --ids 10,20,30 --status closed --dry-run -# 危险批量删除:必须先 dry-run,真实执行还要 --yes -gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run -gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes +# 批量更新 Issue(状态 + 里程碑 + 负责人) +gitlink-cli issue +batch-update --ids 10,20,30 --status closed --milestone 5 --assignees 100 + +# 预览批量删除 Issue +gitlink-cli issue +batch-delete --ids 10,20,30 --dry-run + +# 确认批量删除 Issue +gitlink-cli issue +batch-delete --ids 10,20,30 --confirm # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" @@ -79,14 +84,6 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ``` -## 批量维护安全约束 - -- `issue +batch-close --numbers` 使用网页 URL 中的 Issue 编号,即 `project_issues_index`。 -- `issue +batch-update --ids` 和 `issue +batch-delete --ids` 使用 OpenAPI 返回的 API issue id,不是网页 Issue 编号。 -- 执行 `batch-update` / `batch-delete` 前,先用 `issue +list` 或 `issue +view` 确认 id 来源。 -- 写操作先执行 `--dry-run`,展示 `method`、`path`、`body` 给用户确认。 -- `batch-delete` 是破坏性操作,真实执行必须显式传 `--yes`。 - ## Raw API 补充 ```bash diff --git a/skills/gitlink-issue/examples/issue-workflow.md b/skills/gitlink-issue/examples/issue-workflow.md new file mode 100644 index 0000000..d877651 --- /dev/null +++ b/skills/gitlink-issue/examples/issue-workflow.md @@ -0,0 +1,84 @@ +# Issue 管理完整工作流示例 + +**场景**:项目维护者需要批量管理 Issue:创建、分类、关闭、评论。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 + +## 工作流步骤 + +### Step 1:列出项目 Issue + +```bash +# 查看所有开放的 Issue +gitlink-cli issue +list --owner myorg --repo myproject --state open --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { + "number": 42, + "subject": "登录页面报错 500", + "status_id": 1, + "priority_id": 2, + "author": "user_a", + "created_at": "2026-05-20T10:00:00+08:00" + } + ] +} +``` + +### Step 2:创建 Issue + +```bash +gitlink-cli issue +create \ + --owner myorg --repo myproject \ + --title "Bug: 搜索结果排序异常" \ + --body "## 复现步骤\n1. 打开搜索页面\n2. 输入关键词\n3. 点击搜索\n\n## 预期结果\n结果按相关度排序\n\n## 实际结果\n结果顺序随机" +``` + +### Step 3:批量分类 Issue + +```bash +# 查看可用标签 +gitlink-cli issue +tags --owner myorg --repo myproject + +# 批量更新 Issue 标签(加 --dry-run 预览) +gitlink-cli issue +batch-update --ids 10,20,30 --tag-ids 1,3 --dry-run + +# 确认后执行 +gitlink-cli issue +batch-update --ids 10,20,30 --tag-ids 1,3 +``` + +### Step 4:添加评论和关闭 Issue + +```bash +# 为 Issue 添加评论 +gitlink-cli issue +comment --number 42 --body "已修复,请更新到 v1.2.0 验证" + +# 关闭单个 Issue +gitlink-cli issue +close --number 42 + +# 批量关闭已解决的 Issue +gitlink-cli issue +batch-close --owner myorg --repo myproject --numbers 42,43,44 --dry-run +gitlink-cli issue +batch-close --owner myorg --repo myproject --numbers 42,43,44 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli issue +list --state open --format json +gitlink-cli issue +create --title "..." --body "..." +gitlink-cli issue +view --number --format json +gitlink-cli issue +update --number --title "..." --tag-ids +gitlink-cli issue +comment --number --body "..." +gitlink-cli issue +close --number +gitlink-cli issue +batch-close --numbers 1,2,3 +gitlink-cli issue +batch-update --ids 1,2,3 --status closed --dry-run +``` diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-assign.md b/skills/gitlink-issue/references/gitlink-issue-batch-assign.md new file mode 100644 index 0000000..9dbe10f --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-assign.md @@ -0,0 +1,64 @@ +# issue +batch-assign + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +批量分配 Issue 经办人。支持两种模式:统一模式(所有 Issue 分配同一人)、CSV 模式(每行指定不同经办人)。 + +## 命令 + +```bash +# 统一模式:所有 Issue 分配给同一人 +gitlink-cli issue +batch-assign --numbers 42,43 --assignee zhangsan --dry-run + +# CSV 模式:每行指定不同经办人 +gitlink-cli issue +batch-assign --from assign.csv --dry-run + +# 确认执行 +gitlink-cli issue +batch-assign --numbers 42,43 --assignee zhangsan --confirm +``` + +## CSV 格式(CSV 模式) + +必须包含 `number`(或别名)列和 `assignee`(或 `assignee_id`、`assigned_to_id`)列: + +```csv +number,assignee +42,zhangsan +43,lisi +44,wangwu +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--numbers, -n` | 否 | 逗号分隔的 Issue 编号 | +| `--from` | 否 | CSV 文件路径(CSV 模式) | +| `--search` | 否 | 搜索关键词 | +| `--state` | 否 | 配合 `--search` 过滤状态 | +| `--assignee, -a` | 统一模式必填 | 经办人用户名或 ID | +| `--dry-run` | 否 | 仅预览 | +| `--confirm` | 否 | 确认执行 | +| `--max` | 否 | 最大处理数量(默认 100) | +| `--delay` | 否 | 请求间隔毫秒数(默认 0) | +| `--owner` | 否 | 仓库所有者(自动解析) | +| `--repo` | 否 | 仓库名称(自动解析) | +| `--format` | 否 | 输出格式 | +| `--debug` | 否 | 调试输出 | + +## 经办人解析 + +支持用户名和数字 ID。用户名通过 `GET /users/search` API 自动解析为 ID,结果有进程级缓存。 + +## API + +逐条 PATCH:`PATCH /v1/{owner}/{repo}/issues/{number}`,合并 `assigner_ids` 字段。 + +## Workflow + +1. 确认目标 Issue 和经办人。 +2. 先 `--dry-run` 预览。 +3. 确认后加 `--confirm` 执行。 + +> [!CAUTION] +> `--confirm` 执行是 **写操作**。 diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-close.md b/skills/gitlink-issue/references/gitlink-issue-batch-close.md index 7a97abf..7baa094 100644 --- a/skills/gitlink-issue/references/gitlink-issue-batch-close.md +++ b/skills/gitlink-issue/references/gitlink-issue-batch-close.md @@ -17,6 +17,12 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 42,43 # 从 CSV 文件读取 Issue 编号 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv + +# 按搜索条件批量关闭 +gitlink-cli issue +batch-close --search "已过期" --state open --dry-run + +# 确认执行 +gitlink-cli issue +batch-close --numbers 42,43 --confirm ``` ## CSV 格式 @@ -42,13 +48,19 @@ number,title |------|------|------| | `--numbers, -n` | 否 | 逗号分隔的 Issue 编号,例如 `1,2,3` | | `--from` | 否 | 包含 Issue 编号的 CSV 文件 | +| `--search` | 否 | 搜索关键词,匹配的 Issue 将被关闭 | +| `--state` | 否 | 配合 `--search` 过滤状态 | +| `--label` | 否 | 配合 `--search` 过滤标签 | | `--dry-run` | 否 | 仅预览计划操作,不关闭 Issue | +| `--confirm` | 否 | 确认执行 | +| `--max` | 否 | 最大处理数量(默认 100) | +| `--delay` | 否 | 请求间隔毫秒数(默认 0) | | `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | | `--repo` | 否 | 仓库名称(自动从 git remote 解析) | | `--format` | 否 | 输出格式: `json`/`table`/`yaml` | | `--debug` | 否 | 开启调试输出 | -`--numbers` 和 `--from` 至少提供一个。两者同时提供时,会按顺序合并并去重。 +`--numbers`、`--from`、`--search` 至少提供一个。多个来源同时提供时,会按顺序合并并去重。 ## 输出 diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-create.md b/skills/gitlink-issue/references/gitlink-issue-batch-create.md new file mode 100644 index 0000000..c5bdbce --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-create.md @@ -0,0 +1,64 @@ +# issue +batch-create + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过 CSV 文件批量创建 Issue。每行 CSV 对应一个 Issue。 + +## 命令 + +```bash +# 打印 CSV 格式模板 +gitlink-cli issue +batch-create --print-schema + +# 预览 +gitlink-cli issue +batch-create --from issues.csv --dry-run + +# 确认创建 +gitlink-cli issue +batch-create --from issues.csv --confirm +``` + +## CSV 格式 + +| 列名 | 别名 | 必需 | 说明 | +|------|------|------|------| +| title | subject | 是 | Issue 标题 | +| body | description | 否 | Issue 描述 | +| assignee | assignee_id | 否 | 经办人用户名或 ID | +| milestone | fixed_version_id, milestone_id | 否 | 里程碑名称或 ID | +| label | labels | 否 | 标签名称,逗号分隔 | +| priority | priority_id | 否 | 优先级 ID(默认 2=normal) | + +```csv +title,body,assignee,milestone,label,priority +登录页面崩溃,复现步骤:点击登录按钮后白屏,zhangsan,v2.0,bug,3 +新增导出功能,支持 CSV 和 Excel 导出,lisi,v2.1,feature,2 +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--from, -f` | 是 | CSV 文件路径 | +| `--print-schema` | 否 | 打印 CSV 列名模板并退出 | +| `--dry-run` | 否 | 仅预览,不实际创建 | +| `--confirm` | 否 | 确认执行 | +| `--max` | 否 | 最大创建数量(默认 100) | +| `--delay` | 否 | 请求间隔毫秒数(默认 0) | +| `--owner` | 否 | 仓库所有者(自动解析) | +| `--repo` | 否 | 仓库名称(自动解析) | +| `--format` | 否 | 输出格式 | +| `--debug` | 否 | 调试输出 | + +## API + +逐条 POST:`POST /v1/{owner}/{repo}/issues`,每条创建时自动设置 `status_id: 1`(新建)、`priority_id: 2`(正常)、`done_ratio: 0`。 + +## Workflow + +1. 准备 CSV 文件(可用 `--print-schema` 查看格式)。 +2. 先 `--dry-run` 预览。 +3. 确认后加 `--confirm` 执行。 +4. 汇报创建结果。 + +> [!CAUTION] +> `--confirm` 执行是 **写操作**,会实际创建 Issue。 diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-delete.md b/skills/gitlink-issue/references/gitlink-issue-batch-delete.md new file mode 100644 index 0000000..4c6978b --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-delete.md @@ -0,0 +1,27 @@ +# issue +batch-delete + +批量删除多个 Issue。这是**危险操作**,必须使用 `--confirm` 确认。 + +## 使用方法 + +```bash +# 预览删除(不实际删除) +gitlink-cli issue +batch-delete --ids 10,20,30 --dry-run + +# 确认删除 +gitlink-cli issue +batch-delete --ids 10,20,30 --confirm +``` + +## 参数 + +| 参数 | 短选项 | 必需 | 说明 | +|------|--------|------|------| +| `--ids` | `-i` | 是 | 逗号分隔的 Issue ID 列表 | +| `--dry-run` | | 否 | 仅预览,不实际删除 | +| `--confirm` | | 否 | 确认执行删除(必须提供此标志才会执行) | + +## API 端点 + +`DELETE /api/v1/{owner}/{repo}/issues/batch_destroy.json` + +Body: `{"ids": [10, 20, 30]}` diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-label.md b/skills/gitlink-issue/references/gitlink-issue-batch-label.md new file mode 100644 index 0000000..47ab4f3 --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-label.md @@ -0,0 +1,63 @@ +# issue +batch-label + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +批量操作 Issue 标签,支持三种模式: +- **add**:在现有标签基础上追加 +- **remove**:从现有标签中移除 +- **set**:替换为指定标签(覆盖现有) + +## 命令 + +```bash +# 添加标签 +gitlink-cli issue +batch-label --numbers 42,43 --action add --labels bug,urgent --dry-run + +# 移除标签 +gitlink-cli issue +batch-label --numbers 42,43 --action remove --labels deprecated --dry-run + +# 替换标签 +gitlink-cli issue +batch-label --numbers 42,43 --action set --labels bug,feature --dry-run + +# 使用数字标签 ID +gitlink-cli issue +batch-label --numbers 42,43 --action add --label-ids 1,2 --confirm +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--numbers, -n` | 否 | 逗号分隔的 Issue 编号 | +| `--from` | 否 | CSV 文件路径 | +| `--search` | 否 | 搜索关键词 | +| `--state` | 否 | 配合 `--search` 过滤状态 | +| `--action, -a` | 是 | 操作类型:add / remove / set | +| `--labels, -l` | 否 | 标签名称列表,逗号分隔 | +| `--label-ids` | 否 | 标签 ID 列表,逗号分隔(与 `--labels` 互斥) | +| `--dry-run` | 否 | 仅预览 | +| `--confirm` | 否 | 确认执行 | +| `--max` | 否 | 最大处理数量(默认 100) | +| `--delay` | 否 | 请求间隔毫秒数(默认 0) | +| `--owner` | 否 | 仓库所有者(自动解析) | +| `--repo` | 否 | 仓库名称(自动解析) | +| `--format` | 否 | 输出格式 | +| `--debug` | 否 | 调试输出 | + +`--labels` 和 `--label-ids` 必须提供其一,不可同时使用。 + +## 标签解析 + +标签名称通过 `GET /{owner}/{repo}/labels` API 自动解析为 ID,结果按仓库维度缓存。 + +## API + +逐条 PATCH:`PATCH /v1/{owner}/{repo}/issues/{number}`,先读取当前标签列表,再根据 action 合并/移除/替换 `issue_tag_ids`。 + +## Workflow + +1. 确认目标 Issue、操作类型和标签。 +2. 先 `--dry-run` 预览。 +3. 确认后加 `--confirm` 执行。 + +> [!CAUTION] +> `--confirm` 执行是 **写操作**。 diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-open.md b/skills/gitlink-issue/references/gitlink-issue-batch-open.md new file mode 100644 index 0000000..5f68806 --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-open.md @@ -0,0 +1,55 @@ +# issue +batch-open + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +批量重新打开已关闭的 Issue。 + +## 命令 + +```bash +# 预览 +gitlink-cli issue +batch-open --numbers 42,43 --dry-run + +# 确认重新打开 +gitlink-cli issue +batch-open --numbers 42,43 --confirm + +# 从 CSV 读取 +gitlink-cli issue +batch-open --from issues.csv --dry-run + +# 按搜索条件 +gitlink-cli issue +batch-open --search "已修复" --state closed --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--numbers, -n` | 否 | 逗号分隔的 Issue 编号 | +| `--from` | 否 | CSV 文件路径 | +| `--search` | 否 | 搜索关键词 | +| `--state` | 否 | 配合 `--search` 过滤状态 | +| `--label` | 否 | 配合 `--search` 过滤标签 | +| `--dry-run` | 否 | 仅预览 | +| `--confirm` | 否 | 确认执行 | +| `--max` | 否 | 最大处理数量(默认 100) | +| `--delay` | 否 | 请求间隔毫秒数(默认 0) | +| `--owner` | 否 | 仓库所有者(自动解析) | +| `--repo` | 否 | 仓库名称(自动解析) | +| `--format` | 否 | 输出格式 | +| `--debug` | 否 | 调试输出 | + +`--numbers`、`--from`、`--search` 至少提供一个,可同时使用(结果会合并去重)。 + +## API + +逐条 PATCH:`PATCH /v1/{owner}/{repo}/issues/{number}`,`status_id: 1`。 + +## Workflow + +1. 与用户确认目标 Issue。 +2. 先 `--dry-run` 预览。 +3. 用户确认后加 `--confirm` 执行。 +4. 汇报结果。 + +> [!CAUTION] +> `--confirm` 执行是 **写操作**,执行前必须确认用户意图。 diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-update.md b/skills/gitlink-issue/references/gitlink-issue-batch-update.md new file mode 100644 index 0000000..fd3c6f0 --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-update.md @@ -0,0 +1,93 @@ +# issue +batch-update + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +支持两种模式: +- **`--ids` 统一模式**:所有 Issue 统一更新相同字段,一次服务端批量 API 调用 +- **`--from CSV` 模式**:逐条差异化更新,每条 Issue 可以有不同修改 + +## 命令 + +### --ids 统一模式 + +```bash +# 预览 +gitlink-cli issue +batch-update --ids 10,20,30 --status closed --dry-run + +# 批量关闭并指定里程碑 +gitlink-cli issue +batch-update --ids 10,20,30 --status closed --milestone 5 + +# 批量更新标签和负责人 +gitlink-cli issue +batch-update --ids 10,20,30 --labels 1,2 --assignees 100 +``` + +### --from CSV 模式 + +```bash +# 预览 +gitlink-cli issue +batch-update --from updates.csv --dry-run + +# 确认执行 +gitlink-cli issue +batch-update --from updates.csv --confirm +``` + +## 参数 + +| 参数 | 短选项 | 模式 | 必填 | 说明 | +|------|--------|------|------|------| +| `--ids` | `-i` | 统一 | 统一模式必填 | 逗号分隔的 Issue ID | +| `--status` | `-s` | 统一 | 否 | 新状态:open / closed | +| `--priority` | `-p` | 统一 | 否 | 优先级 ID | +| `--milestone` | `-m` | 统一 | 否 | 里程碑 ID | +| `--labels` | `-l` | 统一 | 否 | 逗号分隔的标签 ID | +| `--assignees` | `-a` | 统一 | 否 | 逗号分隔的负责人用户 ID | +| `--from` | | CSV | CSV 模式必填 | CSV 文件路径 | +| `--dry-run` | | 共享 | 否 | 仅预览,不实际修改 | +| `--confirm` | | CSV | 否 | CSV 模式确认执行 | +| `--max` | | CSV | 否 | 最大处理数量(默认 100) | +| `--delay` | | CSV | 否 | 请求间隔毫秒数(默认 0) | +| `--owner` | | 共享 | 否 | 仓库所有者(自动解析) | +| `--repo` | | 共享 | 否 | 仓库名称(自动解析) | +| `--format` | | 共享 | 否 | 输出格式 | +| `--debug` | | 共享 | 否 | 调试输出 | + +`--ids` 和 `--from` 二选一,决定使用哪种模式。 + +## CSV 格式(--from 模式) + +CSV 第一行为列名,后续每行为一条更新。列名支持中英文别名: + +| 列名 | 别名 | API 字段 | 说明 | +|------|------|----------|------| +| title | | subject | 新标题 | +| body | | description | 新描述 | +| state | | status_id | open/closed 或数字状态 ID | +| assignee | | assigner_ids | 经办人用户名或 ID | +| milestone | milestone_id | milestone_id | 里程碑名称或 ID | +| label | labels | issue_tag_ids | 标签名称或 ID,逗号分隔 | +| priority | priority_id | priority_id | 优先级数字 ID | + +**必须包含 `number` / `issue_number` / `project_issues_index` 列**。空值列表示不修改该字段。 + +```csv +number,title,state,assignee,milestone,label,priority +42,修复后的标题,closed,zhangsan,v2.0,bug,3 +43,,open,lisi,v1.0,, +``` + +## API + +| 模式 | API | +|------|-----| +| `--ids` 统一 | `PATCH /v1/{owner}/{repo}/issues/batch_update`(单次服务端批量调用) | +| `--from CSV` | 逐条 `PATCH /v1/{owner}/{repo}/issues/{number}` | + +## Workflow + +1. 与用户确认目标仓库和更新方式(`--ids` 或 `--from CSV`)。 +2. 先 `--dry-run` 预览。 +3. 确认后执行(CSV 模式需额外 `--confirm`)。 +4. 汇报结果。 + +> [!CAUTION] +> 不带 `--dry-run` 的执行是 **写操作**,执行前必须确认用户意图。 diff --git a/skills/gitlink-issueops/examples/issueops-workflow.md b/skills/gitlink-issueops/examples/issueops-workflow.md new file mode 100644 index 0000000..1eda736 --- /dev/null +++ b/skills/gitlink-issueops/examples/issueops-workflow.md @@ -0,0 +1,31 @@ +# gitlink-issueops · 端到端示例 + +## 场景 +创建 Issue 即经 webhook 触发 Agent 自动处理(事件驱动)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli webhook +create --owner --repo +gitlink-cli webhook +tasks --owner --repo -i --format json +gitlink-cli issue +comment --owner --repo --number <编号> --body "<结果 + 处理链说明>" +gitlink-cli label +create --owner --repo -n "agent:done" -c "#22C55E" +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli webhook +create` | `gitlink-cli webhook +tasks` | `gitlink-cli issue +comment` | `gitlink-cli label +create` + diff --git a/skills/gitlink-label/SKILL.md b/skills/gitlink-label/SKILL.md index 45ee27b..6cb29cb 100644 --- a/skills/gitlink-label/SKILL.md +++ b/skills/gitlink-label/SKILL.md @@ -1,72 +1,42 @@ --- name: gitlink-label version: 1.0.0 -description: "Issue label management: list, create, update, and delete GitLink issue labels (项目标记). Triggered when a user needs to manage labels, set up a triage taxonomy, or tag issues." +description: "标签管理:列出、创建、删除 Issue 标签。当用户需要管理 GitLink 项目标签时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli label --help" --- -# gitlink-label +# gitlink-label(标签操作) -**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. -**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, or `+delete`. -**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** ## Shortcuts -| Shortcut | Description | Operation | -|----------|-------------|-----------| -| `label +list` | List issue labels | Read | -| `label +create` | Create an issue label | Write | -| `label +update` | Update a label, preserving unspecified fields | Write | -| `label +delete` | Delete an issue label | Destructive | +| Shortcut | 说明 | +|----------|------| +| `label +list` | 列出标签 | +| `label +create` | 创建标签 | +| `label +delete` | 删除标签 | -## Examples +## 使用示例 ```bash -# List all labels +# 列出标签 gitlink-cli label +list --owner Gitlink --repo forgeplus -# Filter labels by keyword, sorted by issue count -gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug --sort-by issues_count --sort-direction desc +# 创建标签 +gitlink-cli label +create --owner Gitlink --repo forgeplus --name bug --color "#FF0000" --description "Bug report" -# Create a label (color defaults to #1E90FF when omitted) -gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000" - -# Update only the color; name and description are preserved -gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" - -# Delete a label -gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 +# 删除标签(使用 label ID) +gitlink-cli label +delete --owner Gitlink --repo forgeplus --id 3 ``` -## Parameters +## API 注意事项 -| Command | Key parameters | -|---------|----------------| -| `+list` | `--keyword`, `--only-name`, `--sort-by` (updated_on / created_on / issues_count), `--sort-direction` (asc / desc) | -| `+create` | `--name` (required), `--description`, `--color` (hex, default `#1E90FF`) | -| `+update` | `--id` (required) plus at least one of `--name`, `--description`, `--color` | -| `+delete` | `--id` (required) | - -## API Notes - -- Labels map to the GitLink "项目标记" / `issue_tags` API: `/api/v1/{owner}/{repo}/issue_tags`. -- `--color` must be a hex value (`#RGB` or `#RRGGBB`); it is validated client-side before the API call. -- `+update` first fetches the label's current values from the list endpoint and merges the requested changes, so fields you do not pass are preserved (the API requires `name`, `description`, and `color` together). -- To attach a label to an issue, pass its id via the issue update API field `issue_tag_ids` (see `gitlink-issue`); use `label +list --only-name true` to resolve label ids quickly. - -## Typical workflow: bootstrap a triage taxonomy - -```bash -# Create a consistent label set for issue triage -gitlink-cli label +create -n bug -c "#D73A4A" -d "Confirmed defect" -gitlink-cli label +create -n enhancement -c "#A2EEEF" -d "Feature request" -gitlink-cli label +create -n question -c "#D876E3" -d "Needs clarification" -gitlink-cli label +create -n security -c "#B60205" -d "Security-sensitive" - -# Verify the taxonomy -gitlink-cli label +list --only-name true --format json -``` +- 标签使用 v1 API:`/v1/{owner}/{repo}/issue_tags` +- 创建标签时 color 为可选参数,格式为十六进制颜色值(如 `#FF0000`) +- 删除标签需要标签 ID,可通过 `label +list` 获取 +- `label +list` 支持排序:`--order-by`(updated_on/created_on/issues_count)和 `--order-direction`(asc/desc) diff --git a/skills/gitlink-label/examples/label-workflow.md b/skills/gitlink-label/examples/label-workflow.md new file mode 100644 index 0000000..1d0fa9e --- /dev/null +++ b/skills/gitlink-label/examples/label-workflow.md @@ -0,0 +1,57 @@ +# 标签管理完整工作流示例 + +**场景**:项目维护者需要创建和管理 Issue 标签体系。 + +## 工作流步骤 + +### Step 1:查看现有标签 + +```bash +gitlink-cli label +list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { "id": 1, "name": "bug", "color": "#FF0000" }, + { "id": 2, "name": "enhancement", "color": "#00FF00" }, + { "id": 3, "name": "documentation", "color": "#0075CA" } + ] +} +``` + +### Step 2:创建标签 + +```bash +# 创建 bug 标签 +gitlink-cli label +create --name "bug" --color "#FF0000" + +# 创建新人友好标签 +gitlink-cli label +create --name "good first issue" --color "#7057FF" + +# 创建优先级标签 +gitlink-cli label +create --name "priority: high" --color "#FF6600" +``` + +### Step 3:更新和删除标签 + +```bash +# 更新标签颜色 +gitlink-cli label +update --id 1 --color "#E74C3C" + +# 删除标签(⚠️ 需确认) +gitlink-cli label +delete --id 5 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli label +list --owner --repo --format json +gitlink-cli label +create --name --color +gitlink-cli label +update --id --name --color +gitlink-cli label +delete --id +``` diff --git a/skills/gitlink-license-compliance/examples/license-compliance-workflow.md b/skills/gitlink-license-compliance/examples/license-compliance-workflow.md new file mode 100644 index 0000000..034661e --- /dev/null +++ b/skills/gitlink-license-compliance/examples/license-compliance-workflow.md @@ -0,0 +1,55 @@ +# 许可证合规检查完整工作流示例 + +**场景**:项目维护者需要检查项目的依赖许可证是否合规。 + +## 工作流步骤 + +### Step 1:获取项目依赖文件 + +```bash +# Go 项目 +gitlink-cli repo +raw --owner myorg --repo myproject --path go.mod + +# Node.js 项目 +gitlink-cli repo +raw --owner myorg --repo myproject --path package.json + +# Python 项目 +gitlink-cli repo +raw --owner myorg --repo myproject --path requirements.txt +``` + +### Step 2:检查项目许可证 + +```bash +# 查看项目自身许可证 +gitlink-cli repo +raw --owner myorg --repo myproject --path LICENSE +``` + +### Step 3:生成合规报告 + +AI 分析依赖许可证后生成报告: + +```markdown +## 📜 许可证合规检查报告 + +### 项目许可证:Apache-2.0 + +### 依赖分析 + +| 依赖 | 许可证 | 兼容性 | 风险 | +|------|--------|:------:|:----:| +| library-a | MIT | ✅ 兼容 | 低 | +| library-b | Apache-2.0 | ✅ 兼容 | 低 | +| library-c | GPL-3.0 | ⚠️ 注意 | 高 | +| library-d | BSD-3-Clause | ✅ 兼容 | 低 | + +### 建议 +- ⚠️ library-c 使用 GPL-3.0 许可证,可能要求项目也使用 GPL 许可 +- ✅ 其他依赖许可证与 Apache-2.0 兼容 +``` + +--- + +## 注意事项 + +- GPL/AGPL 许可证具有传染性,需特别关注 +- 建议定期检查依赖许可证变更 diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md index 61f8c50..a57e848 100644 --- a/skills/gitlink-member/SKILL.md +++ b/skills/gitlink-member/SKILL.md @@ -1,57 +1,41 @@ --- name: gitlink-member -description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。" +version: 1.0.0 +description: "项目成员管理:列出、添加、移除项目成员。当用户需要管理 GitLink 项目协作成员时触发。" metadata: + requires: + bins: ["gitlink-cli"] cliHelp: "gitlink-cli member --help" --- -# gitlink-member(仓库成员管理) +# gitlink-member(成员操作) -当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时使用本 Skill。 +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** -## 常用命令 +## Shortcuts -| 命令 | 用途 | -|------|------| -| `member +list` | 列出仓库成员 | -| `member +add` | 通过用户 ID 添加仓库成员 | -| `member +batch-add` | 通过用户 ID 列表或 CSV 批量添加成员 | -| `member +remove` | 通过用户 ID 移除仓库成员 | -| `member +role` | 修改成员角色 | -| `member +invite-link` | 获取或生成当前邀请链接 | -| `member +invite-info` | 查看邀请链接信息 | -| `member +accept-invite` | 接受邀请链接 | +| Shortcut | 说明 | +|----------|------| +| `member +list` | 列出项目成员 | +| `member +add` | 添加项目成员 | +| `member +remove` | 移除项目成员 | -## 示例 +## 使用示例 ```bash -# 列出仓库成员 -gitlink-cli member +list --owner Gitlink --repo forgeplus +# 列出项目成员 +gitlink-cli member +list --owner myuser --repo myrepo -# 添加成员 -gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 +# 添加成员(使用用户 ID) +gitlink-cli member +add --owner myuser --repo myrepo --user-id 42 -# 批量添加前预览 -gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run - -# 从 CSV 批量添加。CSV 支持 user_id、userid、id 列;无表头时读取第一列。 -gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv - -# 修改角色。角色支持 Manager、Developer、Reporter,也支持小写别名。 -gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer - -# 获取或生成当前邀请链接。role 支持 manager、developer、reporter;apply 表示是否需要审核。 -gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true - -# 查看邀请链接信息 -gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign - -# 接受邀请链接 -gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign +# 移除成员 +gitlink-cli member +remove --owner myuser --repo myrepo --user-id 42 ``` -## 安全规则 +## API 注意事项 -- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。 -- 批量添加前优先使用 `--dry-run` 预览。 -- 避免在公开日志中暴露邀请链接的完整 `sign`。 +- 成员 API 使用非 v1 路径:`/{owner}/{repo}/collaborators` +- 添加和移除成员需要**用户 ID**(数字),不是用户名。可通过 `user +info` 获取用户 ID +- 移除成员调用 DELETE `/{owner}/{repo}/collaborators/remove` diff --git a/skills/gitlink-member/examples/member-workflow.md b/skills/gitlink-member/examples/member-workflow.md new file mode 100644 index 0000000..a2c338e --- /dev/null +++ b/skills/gitlink-member/examples/member-workflow.md @@ -0,0 +1,54 @@ +# 成员管理完整工作流示例 + +**场景**:项目维护者需要管理仓库的协作者和团队成员。 + +## 工作流步骤 + +### Step 1:查看仓库成员 + +```bash +gitlink-cli member +list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { "id": 100, "name": "张三", "login": "zhangsan", "role": "Manager" }, + { "id": 101, "name": "李四", "login": "lisi", "role": "Developer" } + ] +} +``` + +### Step 2:添加成员 + +```bash +# 添加仓库协作者 +gitlink-cli member +add --owner myorg --repo myproject --user-id 102 --role Developer +``` + +### Step 3:修改成员角色 + +```bash +# 将成员提升为 Manager +gitlink-cli member +update --owner myorg --repo myproject --user-id 101 --role Manager +``` + +### Step 4:移除成员 + +```bash +# 移除仓库协作者(⚠️ 需确认) +gitlink-cli member +remove --owner myorg --repo myproject --user-id 101 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli member +list --owner --repo --format json +gitlink-cli member +add --user-id --role +gitlink-cli member +update --user-id --role +gitlink-cli member +remove --user-id +``` diff --git a/skills/gitlink-milestone/SKILL.md b/skills/gitlink-milestone/SKILL.md index cd77948..b979271 100644 --- a/skills/gitlink-milestone/SKILL.md +++ b/skills/gitlink-milestone/SKILL.md @@ -1,66 +1,52 @@ --- name: gitlink-milestone version: 1.0.0 -description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones." +description: "里程碑管理:创建、查看、关闭、删除里程碑。当用户需要管理 GitLink 项目里程碑时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli milestone --help" --- -# gitlink-milestone +# gitlink-milestone(里程碑操作) -**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. -**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`. -**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** ## Shortcuts -| Shortcut | Description | Operation | -|----------|-------------|-----------| -| `milestone +list` | List repository milestones | Read | -| `milestone +create` | Create a milestone | Write | -| `milestone +view` | View milestone details and linked issues | Read | -| `milestone +update` | Update milestone fields | Write | -| `milestone +delete` | Delete a milestone | Destructive | -| `milestone +close` | Close a milestone | Write | -| `milestone +reopen` | Reopen a closed milestone | Write | +| Shortcut | 说明 | +|----------|------| +| `milestone +list` | 列出里程碑 | +| `milestone +create` | 创建里程碑 | +| `milestone +view` | 查看里程碑详情(含关联 Issue) | +| `milestone +close` | 关闭里程碑 | +| `milestone +delete` | 删除里程碑 | -## Examples +## 使用示例 ```bash -# List open milestones -gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening +# 列出里程碑 +gitlink-cli milestone +list --owner Gitlink --repo forgeplus -# Create a milestone -gitlink-cli milestone +create --owner Gitlink --repo forgeplus \ - --name v1.0 --description "First stable release" --due-date 2026-07-01 +# 按状态筛选 +gitlink-cli milestone +list --owner Gitlink --repo forgeplus --status open -# View milestone details and linked opened issues -gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened +# 创建里程碑 +gitlink-cli milestone +create --owner Gitlink --repo forgeplus --name "v2.0" --description "Second major release" --due-date 2026-09-01 -# Update the due date -gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01 +# 查看里程碑详情 +gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 5 -# Close and reopen -gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7 -gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7 +# 关闭里程碑 +gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 5 + +# 删除里程碑 +gitlink-cli milestone +delete --owner Gitlink --repo forgeplus --id 5 ``` -## Parameters +## API 注意事项 -| Command | Key parameters | -|---------|----------------| -| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` | -| `+create` | `--name`, `--description`, `--due-date` | -| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` | -| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` | -| `+delete` | `--id` | -| `+close` / `+reopen` | `--id` | - -## API Notes - -- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`. -- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`. -- `--due-date` maps to the GitLink API field `effective_date`. -- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API. +- 里程碑使用 v1 API:`/v1/{owner}/{repo}/milestones` +- `milestone +view` 支持通过 `--category` 参数筛选关联 Issue(all/opened/closed) +- `milestone +close` 调用更新状态接口,status 设为 "closed" diff --git a/skills/gitlink-milestone/examples/milestone-workflow.md b/skills/gitlink-milestone/examples/milestone-workflow.md new file mode 100644 index 0000000..ce6250f --- /dev/null +++ b/skills/gitlink-milestone/examples/milestone-workflow.md @@ -0,0 +1,69 @@ +# 里程碑管理完整工作流示例 + +**场景**:项目维护者需要创建里程碑来规划版本迭代。 + +## 工作流步骤 + +### Step 1:查看里程碑列表 + +```bash +gitlink-cli milestone +list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { + "id": 5, + "name": "v1.3.0", + "description": "用户体验升级", + "due_date": "2026-07-01", + "open_issues": 3, + "closed_issues": 12 + } + ] +} +``` + +### Step 2:创建里程碑 + +```bash +gitlink-cli milestone +create \ + --owner myorg --repo myproject \ + --name "v1.4.0" \ + --description "性能优化与稳定性提升" \ + --due-date "2026-08-01" +``` + +### Step 3:关联 Issue 到里程碑 + +```bash +# 将 Issue 分配到里程碑 +gitlink-cli issue +update --number 156 --milestone 6 + +# 批量分配 +gitlink-cli issue +batch-update --ids 156,157,158 --milestone 6 +``` + +### Step 4:更新和关闭里程碑 + +```bash +# 更新里程碑描述 +gitlink-cli milestone +update --id 6 --description "更新后的描述" + +# 版本发布后关闭里程碑 +gitlink-cli milestone +close --id 5 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli milestone +list --owner --repo --format json +gitlink-cli milestone +create --name --description "..." --due-date +gitlink-cli milestone +update --id --description "..." +gitlink-cli milestone +close --id +``` diff --git a/skills/gitlink-notification-digest/examples/notification-digest-workflow.md b/skills/gitlink-notification-digest/examples/notification-digest-workflow.md new file mode 100644 index 0000000..848693a --- /dev/null +++ b/skills/gitlink-notification-digest/examples/notification-digest-workflow.md @@ -0,0 +1,28 @@ +# gitlink-notification-digest · 端到端示例 + +## 场景 +汇总 GitLink 通知按类型分类,生成摘要(可批量标记已读)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner --repo `(git 仓库内可自动解析) + +## 分步操作 + +该 Skill 为 AI 工作流型,在 Claude Code 里用自然语言触发效果最佳: + +> 「读 skills/gitlink-notification-digest/SKILL.md,<具体任务描述>」 + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +触发语 → AI 读 `skills/gitlink-notification-digest/SKILL.md` 编排 + diff --git a/skills/gitlink-onboarding/SKILL.md b/skills/gitlink-onboarding/SKILL.md index 667f612..88c44eb 100644 --- a/skills/gitlink-onboarding/SKILL.md +++ b/skills/gitlink-onboarding/SKILL.md @@ -1,232 +1,364 @@ --- name: gitlink-onboarding version: 1.0.0 -description: "新人入门引导:帮助新贡献者发现适合入门的 Issue、了解项目贡献流程。当用户想参与项目贡献但不知从何入手、寻找入门任务、或询问如何开始贡献代码时触发。" +description: "新人引导:为开源项目新贡献者提供从环境搭建到首次提交的完整引导。当用户提到「新人引导」「新手入门」「good first issue」「贡献指南」「如何参与」「onboarding」等场景时触发。" metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli issue --help" + cliHelp: "gitlink-cli --help" --- # gitlink-onboarding(新人引导) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 本 Skill 为只读操作,不会修改仓库任何内容。无需用户额外确认即可执行。** -**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 ---- +## 工作流概览 -## 功能概述 +本 Skill 为开源项目新贡献者提供从零到一的完整引导体验,涵盖环境搭建、项目理解、Issue 选择、代码修改到提交 PR 的全过程。 -帮助新贡献者快速了解项目并找到适合入门的任务: - -1. **项目总览** — 获取仓库基本信息(语言、分支、贡献者规模) -2. **入门 Issue 发现** — 从开放 Issue 中筛选适合新手的任务 -3. **贡献指南** — 生成 Fork → Branch → PR 的完整操作步骤 +| 阶段 | 操作 | AI Agent 角色 | +|------|------|--------------| +| ① 项目概览 | 拉取仓库信息、README、目录结构 | 执行 CLI 命令采集项目信息 | +| ② 环境搭建 | 引导安装依赖、配置开发环境 | 根据项目类型生成环境搭建指南 | +| ③ 寻找任务 | 搜索 good-first-issue 标签的 Issue | 推荐、筛选适合新人的 Issue | +| ④ 代码引导 | 分析 Issue 对应的代码位置 | 生成代码定位和修改指引 | +| ⑤ 提交贡献 | Fork → Branch → Commit → PR | 引导完成 Fork 工作流 | +| ⑥ 发布引导评论 | 在 Issue 中添加新人引导评论 | 自动生成个性化引导内容 | --- -## 工作流:新人任务发现与引导 +## 详细工作流 -### Step 1:获取项目概览 +### 工作流 1:项目新人入门(Project Onboarding) + +**场景**:新人想要参与一个 GitLink 项目,需要了解项目信息和上手指南。 + +#### Step 1:获取项目概览 ```bash +# 获取仓库基本信息 gitlink-cli repo +info --owner --repo --format json + +# 获取 README 内容 +gitlink-cli repo +readme --owner --repo + +# 获取语言统计 +gitlink-cli repo +languages --owner --repo --format json + +# 获取贡献者列表 +gitlink-cli repo +contributors --owner --repo --format json + +# 获取目录结构(查看 src 目录) +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master' ``` -从返回数据中提取: +#### Step 2:生成环境搭建指南 -| 字段 | 用途 | +根据项目的语言和技术栈,AI 生成对应的环境搭建指南: + +**Go 项目模板:** +```markdown +## 🚀 环境搭建指南 + +### 前置要求 +- Go 1.21+ +- Git +- gitlink-cli(已安装) + +### 步骤 +1. Fork 项目:`gitlink-cli repo +fork --owner --repo ` +2. Clone 你的 Fork:`git clone https://www.gitlink.org.cn//.git` +3. 添加 upstream:`git remote add upstream https://www.gitlink.org.cn//.git` +4. 安装依赖:`go mod download` +5. 验证构建:`go build ./...` +6. 运行测试:`go test ./...` +``` + +**Python 项目模板:** +```markdown +## 🚀 环境搭建指南 + +### 前置要求 +- Python 3.10+ +- Git +- gitlink-cli(已安装) + +### 步骤 +1. Fork 项目:`gitlink-cli repo +fork --owner --repo ` +2. Clone 你的 Fork:`git clone https://www.gitlink.org.cn//.git` +3. 创建虚拟环境:`python -m venv venv && source venv/bin/activate` +4. 安装依赖:`pip install -e ".[dev]"` +5. 运行测试:`pytest tests/` +``` + +#### Step 3:输出项目结构分析 + +AI 根据仓库信息和目录结构,输出项目概览报告: + +```markdown +## 📋 项目概览 — / + +| 信息 | 详情 | |------|------| -| `full_name` | 确认仓库正确 | -| `default_branch` | 后续分支操作的目标分支(GitLink 通常是 `master`) | -| `contributor_users_count` | 判断社区活跃度 | -| `issues_count` | 了解任务池大小 | -| `size` | 判断项目规模 | -| `description` | 了解项目用途 | +| 项目名称 | | +| 描述 | | +| 主要语言 | | +| 开源协议 | | +| 贡献者数 | | +| 开放 Issue | | +| 开放 PR | | -### Step 2:扫描开放 Issue +### 📁 核心目录 +- `src/` — 源代码 +- `tests/` — 测试 +- `doc/` — 文档 +- `cmd/` — CLI 入口 -```bash -gitlink-cli issue +list --owner --repo --state open --format json +### 🤝 贡献流程 +1. Fork → Branch → Code → Test → PR +2. 遵循 Conventional Commits 规范 +3. PR 需要通过 CI 检查和 Code Review ``` -返回的 Issue 数组中,关注以下字段: -- `subject` — Issue 标题 -- `project_issues_index` — Issue 编号(用于 `+view` 的 `--number` 参数) -- `status_id` — 状态(1=新增, 2=正在解决, 3=已解决, 5=关闭) -- `tags` — 标签数组,每个元素含 `name` 字段 -- `assigners` — 已分配人(空数组 = 无人认领) -- `priority` — 优先级(null 或 `{"name": "正常"/"紧急"/...}`) -- `created_at` / `updated_at` — 时间信息 +--- -> ⚠️ **已知问题**:`--state open` 过滤不准确,返回列表可能包含已关闭的 Issue。需要在客户端按 `status_id` 过滤:仅保留 `status_id` 为 1(新增)或 2(正在解决)。 +### 工作流 2:寻找适合新人的 Issue -### Step 3:预过滤 + 筛选入门级 Issue +**场景**:新人不知道从哪里入手,需要推荐适合新手的任务。 -**第一步:客户端状态过滤** - -忽略 `--state` 参数的实际效果,从返回结果中手动过滤: -- 保留:`status_id` = 1(新增)或 2(正在解决) -- 排除:`status_id` = 3(已解决)、5(关闭) -- `status_id` = 0(未知状态):可纳入候选但需特别标注"状态未知,建议先评论确认" - -**第二步:根据标签/标题筛选入门级 Issue** - -标签匹配(优先级从高到低): -1. 标签名含 `good first issue`、`good-first-issue` → 官方标记的入门任务 -2. 标签名含 `help wanted`、`help-wanted` → 维护者明确求帮助 -3. 标签名含 `easy`、`beginner`、`新手`、`入门`、`低难度` → 社区约定的简单任务 -4. 标签名含 `bug`、`fix` 且标题含 `修复`、`fix` → 修复类任务通常范围明确 -5. 标签名含 `documentation`、`docs`、`文档` → 文档类任务对新手友好 - -辅助判断(无标签时): -- `assigners` 为空 → 无人认领 -- `priority` 为 null 或 `name` = "正常" → 不紧急 -- 标题含 `优化`、`改进`、`添加`、`新增` → 可能是功能增强,范围弹性大 - -过滤规则: -- 已分配(`assigners` 非空)→ 排除(除非标签明确是 `help wanted`) -- 标题含 `紧急`、`hotfix`、`安全` → 排除(不适合新手) - -### Step 4:深入查看候选 Issue - -对筛选出的每个候选 Issue(建议 3~5 个),获取详情: +#### Step 1:搜索 good-first-issue ```bash -gitlink-cli issue +view --owner --repo --number --format json +# 搜索带 good-first-issue 标签的 Issue +gitlink-cli search +issues --owner --repo --keyword "good first issue" --category opened + +# 查看所有打开的 Issue +gitlink-cli issue +list --state open --format json + +# 获取标签列表(寻找新人友好标签)—— 标签查询暂未封装 Shortcut,用 Raw API +gitlink-cli api GET /v1///issue_tags --query 'page=1&limit=50' ``` -从返回数据中确认: -- `description` — 任务描述是否清晰、有可执行的步骤 -- `comment_journals_count` — 是否有讨论历史(有讨论 = 需求更明确) -- `start_date` / `due_date` — 是否有时间限制 +#### Step 2:分析 Issue 新人友好度 -### Step 5:生成新人引导报告 +AI 对每个开放的 Issue 进行新人友好度评估: -将所有信息组织为以下格式输出。 +| 评估维度 | 高友好 ✅ | 中友好 🟡 | 低友好 🔴 | +|---------|----------|----------|----------| +| 标题清晰度 | 明确描述问题和期望 | 模糊但可理解 | 标题不清 | +| 描述完整度 | 有复现步骤、预期结果 | 有简要描述 | 只有标题 | +| 代码定位 | 标注了文件/函数 | 可推断位置 | 无任何定位信息 | +| 改动范围 | 单文件、<50 行 | 多文件或 >50 行 | 涉及架构改动 | +| 难度标签 | good-first-issue / easy | medium | hard / critical | + +#### Step 3:推荐 Issue 列表 + +```markdown +## 🎯 推荐新手任务 + +### ⭐ 强烈推荐(新人友好度:⭐⭐⭐) + +1. **Issue #** — + - 📁 涉及文件:`<file_path>` + - 📝 改动范围:约 <n> 行 + - 💡 提示:<具体修改建议> + - 🔗 链接:https://www.gitlink.org.cn/<owner>/<repo>/issues/<n> + +### ✅ 值得尝试(新人友好度:⭐⭐) + +2. **Issue #<n>** — <title> + - 📝 需要了解:<相关知识> + - 💡 提示:<学习建议> +``` + +--- + +### 工作流 3:Issue 引导评论生成 + +**场景**:项目维护者希望为 good-first-issue 自动生成引导评论,帮助新人快速上手。 + +#### Step 1:获取 Issue 详情 + +```bash +# 查看 Issue 详情 +gitlink-cli issue +view --number <issue_number> --format json + +# 获取相关文件内容(用于代码定位)—— 原始文件读取暂未封装 Shortcut,用 Raw API +gitlink-cli api GET /<owner>/<repo>/raw/master/<file_path> +``` + +#### Step 2:生成引导评论 + +AI 根据 Issue 内容生成结构化的引导评论: + +```markdown +## 🌟 欢迎贡献! + +感谢你对本项目的关注!这是一个 **good first issue**,非常适合首次贡献者。 + +### 📋 任务描述 +<用自己的话重述 Issue 内容> + +### 🗺️ 代码定位 +- 需要修改的文件:`<file_path>` +- 相关函数/类:`<function_name>`(第 <n> 行附近) +- 依赖的上下文:`<related_file>` + +### ✏️ 修改步骤 +1. **Fork 项目** + ```bash + gitlink-cli repo +fork --owner <owner> --repo <repo> + ``` +2. **创建分支** + ```bash + git checkout -b fix/<branch-name> + ``` +3. **定位代码** + - 打开 `<file_path>` + - 找到 `<function_name>` 函数 + - 理解当前逻辑:<简要说明> +4. **实施修改** + - <具体修改步骤> + - 预期改动约 <n> 行 +5. **测试验证** + ```bash + go test ./<package>/... # 或 pytest tests/ + ``` +6. **提交 PR** + ```bash + git add . + git commit -m "fix: <commit-message>" + git push origin fix/<branch-name> + gitlink-cli pr +create --owner <owner> --repo <repo> \ + --head <you>:fix/<branch-name> --base master \ + --title "fix: <title>" + ``` + +### 💡 提示 +- 不确定的地方可以先在 Issue 中提问 +- PR 描述中引用本 Issue:`Fixes #<number>` +- 遵循项目的代码风格和提交规范 + +### ❓ 需要帮助? +如果遇到任何问题,请随时在下方评论,维护者会尽快回复! +``` + +#### Step 3:发布引导评论 + +```bash +# 将引导评论发布到 Issue +gitlink-cli issue +comment \ + --number <issue_number> \ + --body "$(cat <<'EOF' +## 🌟 欢迎贡献! +...引导内容... +EOF +)" +``` + +--- + +### 工作流 4:新人贡献全流程引导 + +**场景**:新人已选定 Issue,需要从 Fork 到提交 PR 的全流程指导。 + +```bash +# Step 1:Fork 仓库 +gitlink-cli repo +fork --owner <owner> --repo <repo> + +# Step 2:Clone Fork +git clone https://www.gitlink.org.cn/<you>/<repo>.git +cd <repo> + +# Step 3:添加 upstream +git remote add upstream https://www.gitlink.org.cn/<owner>/<repo>.git + +# Step 4:创建分支 +git checkout -b fix/<issue-descriptor> + +# Step 5:(用户进行代码修改) + +# Step 6:提交 +git add -A +git commit -m "fix: <description> (#<issue_number>)" + +# Step 7:推送到 Fork +git push origin fix/<issue-descriptor> + +# Step 8:创建 PR +gitlink-cli pr +create \ + --owner <owner> --repo <repo> \ + --head <you>:fix/<issue-descriptor> --base master \ + --title "fix: <title>" \ + --body "## 变更说明\n\nFixes #<issue_number>\n\n### 修改内容\n- ...\n\n### 测试\n- [ ] 单元测试通过\n- [ ] 手动验证" +``` + +--- + +## 新人友好度评估标准 + +用于评估项目是否对新人友好: + +| 维度 | 评估方法 | 数据来源 | +|------|---------|---------| +| README 完整性 | README 是否包含项目介绍、安装步骤、贡献指南 | `repo +readme` | +| Issue 标签 | 是否有 good-first-issue / easy 标签 | `api GET /v1/:owner/:repo/issue_tags` | +| 文档覆盖 | 是否有 Wiki、API 文档 | `wiki +list` | +| CI 配置 | 是否有自动化构建和测试 | `.gitea/workflows/` 或 `.github/workflows/` | +| 维护者响应 | Issue 平均响应时间 | `issue +list` + 创建时间分析 | +| 贡献指南 | 是否有 CONTRIBUTING.md | `api GET /:owner/:repo/raw/master/CONTRIBUTING.md` | --- ## 输出模板 +### 项目新手上手指南 + ```markdown -# 🚀 {{仓库名}} 新人引导报告 +# 🚀 <项目名> 新人上手指南 -## 项目概览 +## 1. 了解项目 +<项目简介 + 技术栈> -| 项目 | 信息 | +## 2. 环境搭建 +<Step-by-step 安装指南> + +## 3. 项目结构 +<目录说明 + 核心模块> + +## 4. 选择任务 +<推荐 Issue 列表> + +## 5. 开始贡献 +<Fork → Branch → Code → PR 流程> + +## 6. 获取帮助 +<社区链接 / 维护者联系 / 文档> +``` + +--- + +## 决策规则 + +| 条件 | 操作 | |------|------| -| 仓库 | {{full_name}} | -| 描述 | {{description}} | -| 主分支 | {{default_branch}} | -| 贡献者数 | {{contributor_users_count}} | -| 开放 Issue | {{issues_count 或实际列表长度}} | -| 项目规模 | {{size}} | - ---- - -## 📋 推荐的入门 Issue - -> 以下 Issue 适合新贡献者,按推荐度排序。 - -### ⭐ 最推荐(官方标记/明确入门级) - -| # | 标题 | 标签 | 推荐理由 | -|---|------|------|----------| -| {{number}} | {{subject}} | {{tags}} | good first issue 官方标记 | -| ... | ... | ... | ... | - -### 👍 推荐(文档/简单修复) - -| # | 标题 | 标签 | 推荐理由 | -|---|------|------|----------| -| {{number}} | {{subject}} | {{tags}} | 文档类任务,无需深入代码 | -| ... | ... | ... | ... | - -### 🤔 可尝试(功能增强,范围需确认) - -| # | 标题 | 标签 | 推荐理由 | -|---|------|------|----------| -| {{number}} | {{subject}} | {{tags}} | 功能明确,建议先评论确认范围 | -| ... | ... | ... | ... | - ---- - -## 📖 贡献流程 - -### 第 1 步:Fork 仓库 - -在 GitLink 网页打开 https://www.gitlink.org.cn/{{owner}}/{{repo}} ,点击右上角 **Fork** 按钮。 - -### 第 2 步:Clone 到本地 - -```bash -git clone https://www.gitlink.org.cn/<你的用户名>/{{repo}}.git -cd {{repo}} -git remote add upstream https://www.gitlink.org.cn/{{owner}}/{{repo}}.git -``` - -### 第 3 步:创建分支 - -```bash -git checkout -b fix/issue-{{number}}-简要描述 -``` - -### 第 4 步:修改 + 提交 - -```bash -git add -A -git commit -m "fix: 简要描述修改内容 (#{{number}})" -``` - -### 第 5 步:Push 并提 PR - -```bash -git push origin fix/issue-{{number}}-简要描述 -gitlink-cli pr +create \ - --owner {{owner}} \ - --repo {{repo}} \ - --head <你的用户名>:fix/issue-{{number}}-简要描述 \ - --base {{default_branch}} \ - --title "fix: 简要描述 (#{{number}})" -``` - -### 第 6 步:在 Issue 下留言 - -在 Issue 页面评论说明你正在处理,避免与他人重复劳动。如果 Issue 已有讨论,先阅读并确认无人认领。 - ---- - -## ⚠️ 注意事项 - -1. **先评论再动手** — 在 Issue 下留言「我来处理这个」,避免重复劳动 -2. **保持 PR 小** — 一个 PR 只解决一个问题,方便维护者 Review -3. **阅读贡献指南** — 如果仓库有 `CONTRIBUTING.md`,先阅读 -4. **不确定就问** — 对需求有疑问,在 Issue 下直接提问 -``` - ---- - -## 异常场景处理 - -| 场景 | 处理方式 | -|------|----------| -| 无开放 Issue | 输出项目概览后,建议用户关注 `watch` 仓库等待新 Issue,或查看已有 PR 了解贡献模式 | -| 所有 Issue 已分配 | 列出已分配 Issue,建议用户在感兴趣的 Issue 下评论询问是否需要帮助 | -| 无入门级标签 | 列出所有未分配的开放 Issue,标注「无明确入门标记,建议根据兴趣自行选择」,优先推荐标题含 `fix`/`doc`/`优化` 的 | -| `repo +info` 返回空 | 检查 owner/repo 是否正确,提示用户确认仓库名 | +| 项目无 README | 提示维护者补充 README,但仍提供基础引导 | +| 无 good-first-issue 标签 | 从开放的 Issue 中推荐最简单的(标题包含"文档""修复""小") | +| Issue 无描述 | 提示用户先在 Issue 中提问获取更多信息 | +| 用户未登录 | 引导执行 `gitlink-cli auth login` | +| 用户无 Fork | 引导执行 Fork 流程 | +| Fork 已存在但未配置 upstream | 引导添加 upstream remote | --- ## 注意事项 -- ✅ **所有命令使用 `--format json`**,确保可解析 -- ✅ **`issue +view` 使用 `--number`(网页编号)**,非数据库 ID -- ✅ **本 Skill 为纯只读**,不会修改仓库 -- ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 -- ⚠️ **Issue 列表可能分页**,如果总数 >20,需要追加 `--page 2` 等参数获取全部 -- ⚠️ **`issue +list --state open` 过滤不准确**,返回列表可能含已关闭 Issue。必须客户端按 `status_id` 二次过滤(保留 1、2,排除 3、5、0) -- ⚠️ **此仓库未使用 Issue 标签系统**,筛选主要依赖标题关键词和 `assigners` 状态。如目标仓库有标签,优先使用标签匹配 +- 引导评论发布前确认用户意图(维护者模式) +- 推荐的 Issue 应标注预估改动范围和难度 +- Fork 工作流严格遵循 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 中的 PR 协作流程 +- 不确定的信息(如代码定位)应明确标注"建议确认" +- 遵循项目的贡献规范(如果存在 CONTRIBUTING.md) +- 所有 CLI 命令使用 `--format json` 以便解析 diff --git a/skills/gitlink-onboarding/examples/onboarding-workflow.md b/skills/gitlink-onboarding/examples/onboarding-workflow.md new file mode 100644 index 0000000..001f44b --- /dev/null +++ b/skills/gitlink-onboarding/examples/onboarding-workflow.md @@ -0,0 +1,197 @@ +# 新人引导完整工作流示例 + +**场景**:一个新贡献者想参与 GitLink 上的开源项目,AI Agent 引导其完成从了解项目到提交首次贡献的全过程。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 目标仓库为公开项目 + +## 工作流步骤 + +### Step 1:获取项目概览 + +```bash +# 获取仓库基本信息 +gitlink-cli repo +info --owner Gitlink --repo forgeplus --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": { + "identifier": "forgeplus", + "name": "ForgePlus", + "description": "开源研发创新平台", + "language": "Ruby", + "default_branch": "master", + "license_name": "Apache-2.0" + } +} +``` + +```bash +# 获取 README +gitlink-cli repo +readme --owner Gitlink --repo forgeplus + +# 获取语言统计 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus --format json + +# 查看贡献者 +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus --format json +``` + +### Step 2:搜索适合新人的 Issue + +```bash +# 搜索 good-first-issue +gitlink-cli search +issues --owner Gitlink --repo forgeplus --keyword "good first issue" --category opened + +# 如果没有 good-first-issue 标签,查看所有开放的 Issue +gitlink-cli issue +list --state open --format json +``` + +**AI 分析 Issue 列表,推荐最适合新人的任务:** + +```markdown +## 🎯 推荐新手任务 + +### ⭐ 强烈推荐 + +1. **Issue #156** — 修复 README 中的过期链接 + - 📁 涉及文件:`README.md` + - 📝 改动范围:约 3 行 + - 💡 提示:找到文档中的链接,替换为新地址即可 + +2. **Issue #203** — 添加缺失的 API 参数文档 + - 📁 涉及文件:`doc/api_reference.md` + - 📝 改动范围:约 15 行 + - 💡 提示:参考代码中的参数定义补充文档 +``` + +### Step 3:为推荐 Issue 生成引导评论 + +```bash +# 查看 Issue 详情 +gitlink-cli issue +view --number 156 --format json +``` + +**AI 生成引导评论:** + +```markdown +## 🌟 欢迎贡献! + +这是一个很适合首次贡献的任务。 + +### 🗺️ 代码定位 +- 需要修改的文件:`README.md`(项目根目录) +- 相关位置:第 45 行附近的"相关链接"章节 + +### ✏️ 修改步骤 +1. Fork 项目 +2. 创建分支 `git checkout -b fix/readme-links` +3. 打开 `README.md`,找到第 45 行的链接 +4. 将 `https://old-domain.com/docs` 替换为 `https://new-domain.com/docs` +5. 本地预览确认链接正确 +6. 提交 PR + +### 💡 提示 +- 只需修改一处,改动量极小 +- 不需要运行测试 +- PR 标题建议:`docs: update broken link in README` +``` + +### Step 4:发布引导评论 + +```bash +gitlink-cli issue +comment \ + --number 156 \ + --body "## 🌟 欢迎贡献!\n\n这是一个很适合首次贡献的任务。\n\n### 🗺️ 代码定位\n- 需要修改的文件:README.md\n\n### ✏️ 修改步骤\n1. Fork 项目\n2. 修改 README.md 中的链接\n3. 提交 PR\n\n### 💡 提示\n只需修改一处链接即可" +``` + +### Step 5:引导新人完成 Fork-PR 流程 + +```bash +# 1. Fork 仓库 +gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# 2. Clone +git clone https://www.gitlink.org.cn/MyUser/forgeplus.git +cd forgeplus + +# 3. 配置 upstream +git remote add upstream https://www.gitlink.org.cn/Gitlink/forgeplus.git + +# 4. 创建分支 +git checkout -b fix/readme-links + +# 5. 修改代码(用户手动操作) +# ... + +# 6. 提交 +git add README.md +git commit -m "docs: update broken link in README" + +# 7. 推送到 Fork +git push origin fix/readme-links + +# 8. 创建 PR +gitlink-cli pr +create \ + --owner Gitlink --repo forgeplus \ + --head MyUser:fix/readme-links --base master \ + --title "docs: update broken link in README" \ + --body "## 变更说明\n\nFixes #156\n\n更新了 README 中的过期链接。" +``` + +### Step 6:生成新人上手指南 + +**AI 输出完整的新人上手指南:** + +```markdown +# 🚀 ForgePlus 新人上手指南 + +## 项目简介 +ForgePlus 是一个开源研发创新平台,主要使用 Ruby 开发。 + +## 环境搭建 +1. Fork 项目:`gitlink-cli repo +fork --owner Gitlink --repo forgeplus` +2. Clone:`git clone https://www.gitlink.org.cn/<you>/forgeplus.git` +3. 安装依赖:`bundle install` +4. 配置数据库:`cp config/database.yml.example config/database.yml` +5. 运行测试:`bundle exec rspec` + +## 推荐的第一个任务 +- Issue #156:修复 README 过期链接(难度:⭐) +- Issue #203:补充 API 文档(难度:⭐⭐) + +## 贡献流程 +Fork → Branch → Code → Test → PR + +## 获取帮助 +在 Issue 中 @maintainer 或在 Wiki 中查阅贡献指南 +``` + +--- + +## 完整命令速览 + +```bash +# 项目信息 +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +gitlink-cli repo +readme --owner <owner> --repo <repo> +gitlink-cli repo +languages --owner <owner> --repo <repo> --format json +gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json + +# 搜索 Issue +gitlink-cli search +issues --owner <owner> --repo <repo> --keyword "good first issue" --category opened +gitlink-cli issue +list --state open --format json +gitlink-cli issue +view --number <n> --format json + +# 发布引导评论 +gitlink-cli issue +comment --number <n> --body "<引导内容>" + +# Fork 和 PR +gitlink-cli repo +fork --owner <owner> --repo <repo> +gitlink-cli pr +create --owner <owner> --repo <repo> --head <you>:<branch> --base master --title "..." --body "..." +``` diff --git a/skills/gitlink-org/SKILL.md b/skills/gitlink-org/SKILL.md index aef61a9..0b3a7ee 100644 --- a/skills/gitlink-org/SKILL.md +++ b/skills/gitlink-org/SKILL.md @@ -18,12 +18,15 @@ metadata: ## Shortcuts -| Shortcut | 说明 | -|----------|------| -| `org +list` | 组织列表 | -| `org +info` | 组织详情 | -| `org +members` | 成员列表 | -| `org +create` | 创建组织 | +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `org +list` | 组织列表 | 否 | +| `org +info` | 组织详情 | 否 | +| `org +members` | 成员列表 | 否 | +| `org +create` | 创建组织 | 是 | +| `org +teams` | 团队列表 | 否 | +| `org +create-team` | 创建团队 | 是 | +| `org +remove-member` | 移除成员 | 是 | ## 使用示例 @@ -32,15 +35,14 @@ gitlink-cli org +list gitlink-cli org +info --id Gitlink gitlink-cli org +members --id Gitlink gitlink-cli org +create --name my-org --description "我的组织" + +# 管理团队 +gitlink-cli org +teams --id 12345 +gitlink-cli org +create-team --id 12345 --name dev-team +gitlink-cli org +remove-member --id 12345 --uid 67890 ``` -## Raw API 补充 +## 注意事项 -```bash -# 组织团队管理 -gitlink-cli api GET /organizations/:id/teams -gitlink-cli api POST /organizations/:id/teams --body '{"name":"dev-team"}' - -# 移除成员 -gitlink-cli api DELETE /organizations/:id/organization_users/:uid -``` +- `org +create` 创建的组织默认为公开 +- 团队管理和成员移除需组织 owner 权限 diff --git a/skills/gitlink-org/examples/org-workflow.md b/skills/gitlink-org/examples/org-workflow.md new file mode 100644 index 0000000..4797784 --- /dev/null +++ b/skills/gitlink-org/examples/org-workflow.md @@ -0,0 +1,35 @@ +# 组织管理完整工作流示例 + +**场景**:组织管理员需要查看组织信息和成员列表。 + +## 工作流步骤 + +### Step 1:查看组织信息 + +```bash +# 列出用户所在的组织 +gitlink-cli org +list --format json +``` + +### Step 2:查看组织成员 + +```bash +# 列出组织成员 +gitlink-cli org +members --owner myorg --format json +``` + +### Step 3:查看组织仓库 + +```bash +# 列出组织下的仓库 +gitlink-cli repo +list --user myorg --format json +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli org +list --format json +gitlink-cli org +members --owner <org> --format json +``` diff --git a/skills/gitlink-pipeline/examples/pipeline-workflow.md b/skills/gitlink-pipeline/examples/pipeline-workflow.md new file mode 100644 index 0000000..78f9edb --- /dev/null +++ b/skills/gitlink-pipeline/examples/pipeline-workflow.md @@ -0,0 +1,68 @@ +# Pipeline 管理完整工作流示例 + +**场景**:开发者需要查看和管理 CI/CD Pipeline 工作流。 + +## 工作流步骤 + +### Step 1:查看 Pipeline 列表 + +```bash +# 列出平台 Pipeline +gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20 +``` + +### Step 2:查看仓库 Pipeline 运行记录 + +```bash +# 列出仓库的 Pipeline 运行记录 +gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml +``` + +### Step 3:查看 Pipeline 详情和日志 + +```bash +# 查看 Pipeline 详情 +gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7 + +# 查看 Pipeline 运行日志 +gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43 + +# 查看运行报告结果 +gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99 +``` + +### Step 4:管理 Pipeline + +```bash +# 启用 Pipeline 工作流(⚠️ 需确认) +gitlink-cli pipeline +enable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run + +# 禁用 Pipeline 工作流 +gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run + +# 删除 Pipeline(⚠️ 危险操作) +gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run +``` + +### Step 5:保存 Pipeline 可视化 YAML + +```bash +# 保存 Pipeline 图形为 YAML +gitlink-cli pipeline +save-yaml --owner Gitlink --repo forgeplus \ + --id 7 --pipeline-json '{"nodes":[]}' --dry-run +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli pipeline +list --owner-id <id> --page <n> --limit <n> +gitlink-cli pipeline +runs --owner <owner> --repo <repo> --ref <branch> --workflow <name> +gitlink-cli pipeline +view --owner <owner> --repo <repo> --id <id> +gitlink-cli pipeline +logs --owner <owner> --repo <repo> --run-id <id> --id <id> --index <n> +gitlink-cli pipeline +run --owner <owner> --repo <repo> --ref <branch> --workflow <name> +gitlink-cli pipeline +enable --owner <owner> --repo <repo> --id <id> --workflow <name> +gitlink-cli pipeline +disable --owner <owner> --repo <repo> --id <id> --workflow <name> +gitlink-cli pipeline +delete --owner <owner> --repo <repo> --id <id> --dry-run +``` diff --git a/skills/gitlink-pm/SKILL.md b/skills/gitlink-pm/SKILL.md index f5333ed..b6f5158 100644 --- a/skills/gitlink-pm/SKILL.md +++ b/skills/gitlink-pm/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-pm -version: 1.0.0 +version: 1.1.0 description: "项目管理(PM):Sprint、看板、周报等项目管理功能。当用户需要使用 GitLink PM 功能时触发。" metadata: requires: @@ -14,11 +14,44 @@ metadata: **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** -GitLink PM 模块提供敏捷项目管理能力,目前通过 Raw API 访问。 +GitLink PM 模块提供敏捷项目管理能力,包括看板、Sprint、周报等功能。 -## API 端点 +## Shortcuts -> 前缀:`/api/pm` +| 命令 | 说明 | 认证 | +|------|------|------| +| `pm +boards` | 查看看板 | Token | +| `pm +sprints` | Sprint Issue 列表 | Token | +| `pm +weekly` | 周报 | Token | +| `pm +tags` | PM Issue 标签 | Token | +| `pm +pipelines` | PM 流水线 | Token | +| `pm +actions` | Action 运行记录 | Token | + +## 使用示例 + +```bash +# 查看项目看板 +gitlink-cli pm +boards + +# 查看 Sprint Issue 列表 +gitlink-cli pm +sprints + +# 查看周报 +gitlink-cli pm +weekly + +# 查看 PM Issue 标签 +gitlink-cli pm +tags + +# 查看 PM 流水线 +gitlink-cli pm +pipelines + +# 查看 Action 运行记录(分页) +gitlink-cli pm +actions --page 2 --limit 10 +``` + +## Raw API + +如需更灵活的访问,可直接调用 Raw API: ```bash # 看板 @@ -42,5 +75,7 @@ gitlink-cli api GET /pm/action_runs --query 'project_id=123' ## 注意事项 -- PM 接口需要项目 ID(`project_id`),可通过 `repo +info` 获取 - PM 功能需要项目开启 PM 模块 +- Shortcut 命令会自动从 git remote 解析 owner/repo 并获取 project_id +- 所有 PM 端点均为只读 GET 请求 +- 如遇到 404 错误,请确认项目已启用 PM 模块 diff --git a/skills/gitlink-pm/examples/pm-workflow.md b/skills/gitlink-pm/examples/pm-workflow.md new file mode 100644 index 0000000..7e38d1f --- /dev/null +++ b/skills/gitlink-pm/examples/pm-workflow.md @@ -0,0 +1,72 @@ +# 项目管理(PM)完整工作流示例 + +**场景**:项目经理需要查看看板、Sprint、周报等项目管理信息。 + +## 工作流步骤 + +### Step 1:查看看板 + +```bash +# 查看项目看板(自动从 git remote 解析) +gitlink-cli pm +boards +``` + +### Step 2:查看 Sprint Issue 列表 + +```bash +# 查看 Sprint 中的 Issue +gitlink-cli pm +sprints +``` + +### Step 3:查看周报 + +```bash +# 生成本周周报 +gitlink-cli pm +weekly +``` + +### Step 4:查看 PM Issue 标签和流水线 + +```bash +# 查看 PM Issue 标签 +gitlink-cli pm +tags + +# 查看 PM 流水线 +gitlink-cli pm +pipelines +``` + +### Step 5:查看 Action 运行记录 + +```bash +# 查看最近的 Action 运行记录 +gitlink-cli pm +actions --page 1 --limit 10 + +# 翻页查看更多 +gitlink-cli pm +actions --page 2 --limit 10 +``` + +### Step 6:通过 Raw API 灵活查询 + +```bash +# 按项目 ID 查看看板 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' + +# 查询 Sprint Issue +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' + +# 查询周报 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli pm +boards +gitlink-cli pm +sprints +gitlink-cli pm +weekly +gitlink-cli pm +tags +gitlink-cli pm +pipelines +gitlink-cli pm +actions --page <n> --limit <n> +``` diff --git a/skills/gitlink-pr-guard/SKILL.md b/skills/gitlink-pr-guard/SKILL.md new file mode 100644 index 0000000..e5a10f2 --- /dev/null +++ b/skills/gitlink-pr-guard/SKILL.md @@ -0,0 +1,173 @@ +--- +name: gitlink-pr-guard +version: 1.0.0 +description: "代码质量看门人:PR 提交后自动跑完「采集→AI Review→CI 检查→汇总评论→质量判定/合并」端到端流水线。当用户需要 PR 质量门禁、自动化代码审查流水线、PR 自动合并看门人、code quality gate 时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli pr --help" +--- + +# gitlink-pr-guard(代码质量看门人) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 合并 PR(`pr +merge`)是写操作,执行前必须确认用户意图;默认只「建议合并」,由人确认后再执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。** + +> **前置条件:** 先读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)(认证与全局参数)、[`../gitlink-code-review/SKILL.md`](../gitlink-code-review/SKILL.md)(Review 分析逻辑)。 + +## 定位:与 gitlink-code-review 的分工 + +| Skill | 职责边界 | +|-------|---------| +| `gitlink-code-review` | **只做 Review**:分析 PR diff,输出结构化审查意见(Critical/Warning/Suggestion) | +| `gitlink-pr-guard`(本 Skill) | **完整看门人**:Review + CI 检查 + 汇总评论 + **质量判定 + 合并决策**,一条流水线 | + +> 本 Skill 是**端到端工作流**(子任务三),串联 `pr` + `code-review` + `ci` + `merge` 多个能力,交付完整质量门禁方案。 + +--- + +## 工作流概览(5 步流水线) + +| 阶段 | 操作 | 命令 | AI Agent 角色 | +|------|------|------|--------------| +| ① 采集变更 | 拉 PR 详情、文件、Diff | `pr +view` / `+files` / `+diff` | 数据采集 | +| ② AI Review | 分析 diff,分级找问题 | (复用 code-review 逻辑) | 逐文件审查 | +| ③ CI 检查 | 查最新构建状态 | `ci +builds` / `+log` | 状态判定 | +| ④ 汇总评论 | 发布结构化审查报告 | `api POST .../reviews` | 生成报告并发布 | +| ⑤ 质量判定 | 按门禁规则判通过/拒绝 | `pr +merge`(达标且确认后) | 决策 + 执行 | + +--- + +## 详细工作流 + +### Step 1:采集 PR 变更 + +```bash +gitlink-cli pr +view --id <pr_id> --format json +gitlink-cli pr +files --id <pr_id> --format json +gitlink-cli pr +diff --id <pr_id> --format json # 完整 diff +gitlink-cli pr +diff --id <pr_id> --stat # 仅统计摘要 +``` + +### Step 2:AI Review(复用 code-review 分析逻辑) + +对每个变更文件按 [`../gitlink-code-review/SKILL.md`](../gitlink-code-review/SKILL.md) 的检查项审查,输出分级意见: +- 🔴 **Critical**:硬编码密钥、SQL/命令注入、路径遍历等安全红线 +- 🟡 **Warning**:错误处理缺失、边界条件、密码明文等 +- 🔵 **Suggestion**:命名、性能、可配置化等优化 +- ✅ **Positive**:值得肯定的设计 + +### Step 3:CI 检查 + +```bash +# 查最新构建 +gitlink-cli ci +builds --owner <owner> --repo <repo> --format json +# 从返回找 PR 对应分支的最新构建,取 status(success/failure/pending) +# 失败时取日志定位原因 +gitlink-cli ci +log --build <build_number> +``` + +### Step 4:发布汇总评论(审查报告) + +```bash +gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ + "body": "<质量看门人报告,见输出模板>", + "event": "COMMENT" +}' +``` + +### Step 5:质量判定(门禁核心) + +按下方「质量门禁规则」判定,达标且**用户确认后**执行合并: + +```bash +# 达标 + 用户确认 → 合并(默认建议,不自动执行) +gitlink-cli pr +merge --id <pr_id> --method squash +``` + +--- + +## 质量门禁规则(决策表) + +本 Skill 的灵魂:把「能否合并」从主观判断变成可量化门禁。 + +| 条件 | 判定 | 动作 | +|------|:----:|------| +| 0 Critical **且** CI = success | ✅ **通过** | 建议合并(确认后 `pr +merge`) | +| 有 Critical(任一) | 🔴 **拒绝** | 请求修改,逐条列 Critical + 文件:行号 | +| CI = failure | 🔴 **拒绝** | 请求修改,附 CI 失败日志摘要 | +| CI = pending | ⏳ **等待** | 等构建完成再判定 | +| 0 Critical + CI success + 有 Warning | 🟡 **通过(带建议)** | 合并 + 评论里列出 Warning 供后续优化 | +| 仅 Suggestion | ✅ **通过** | 合并 + 评论附优化建议 | + +**安全红线**(必须 Critical,不得合并): +- 硬编码 Token / 密钥 / 密码 +- SQL / NoSQL / 命令注入 +- 路径遍历、不安全反序列化、XSS + +--- + +## 输出模板:质量看门人报告 + +```markdown +## 🚪 质量看门人报告 — PR #<id> <title> + +### 📊 质量判定:<✅ 通过 / 🔴 拒绝 / ⏳ 等待 CI> + +| 维度 | 结果 | +|------|------| +| 变更规模 | <n> 文件,+<add> / -<del> | +| 🔴 Critical | <n> | +| 🟡 Warning | <n> | +| 🔵 Suggestion | <n> | +| CI 构建 | <success/failure/pending>(build #<n>) | + +### 🔴 Critical(必须修改) +- <问题> — `<file>:<line>` + > <修改建议> + +### 🟡 Warning(建议修改) +- <问题> — `<file>:<line>` + +### ✅ CI 状态 +- 构建 #<n>:<success/failure>,耗时 <duration> +- 失败原因(如有):<日志摘要> + +### 📋 处置建议 +<根据门禁规则:合并 / 请求修改 / 等 CI> + +--- +*由 gitlink-pr-guard 质量看门人自动生成* +``` + +--- + +## 工作流决策规则 + +| 场景 | 处理 | +|------|------| +| 用户说"帮我把关这个 PR" | 跑完整 5 步,输出报告 + 判定 | +| 用户只要 Review 不要合并 | 跑 Step 1-4,跳过 Step 5(指向 code-review) | +| PR 是 draft 草稿 | 提示先标记 Ready for Review | +| CI 还在跑 | Step 3 返回 pending,报告标 ⏳,建议稍后再判 | +| Critical 数 ≥1 | 报告标 🔴 拒绝,**不合并**,列清 Critical | +| 用户要求自动合并 | 警告风险,仅在 0 Critical + CI success 时执行,且二次确认 | + +--- + +## 注意事项 + +- **合并是写操作**:默认只「建议合并」,`pr +merge` 必须用户确认后执行 +- **CI 对应分支**:`ci +builds` 返回多条,需按 PR 的 source_branch 匹配最新构建 +- **Review 评论量控制**:最多列 3-5 个最严重问题,避免信息过载 +- **草稿 PR**:先提示标记 Ready for Review +- **与 code-review 复用**:Step 2 的审查逻辑直接引用 code-review SKILL.md,不重复定义 +- **可复现**:配套 `pr-guard-workflow.sh` 脚本可端到端复现整个流水线 + +## References + +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证与全局参数 +- [gitlink-code-review](../gitlink-code-review/SKILL.md) — Review 分析逻辑(Step 2 复用) +- [gitlink-pr](../gitlink-pr/SKILL.md) — PR 命令 +- [gitlink-ci](../gitlink-ci/SKILL.md) — CI 命令 diff --git a/skills/gitlink-pr-guard/examples/pr-guard-workflow.md b/skills/gitlink-pr-guard/examples/pr-guard-workflow.md new file mode 100644 index 0000000..5c5e835 --- /dev/null +++ b/skills/gitlink-pr-guard/examples/pr-guard-workflow.md @@ -0,0 +1,35 @@ +# gitlink-pr-guard · 端到端示例 + +## 场景 +PR 提交后跑完 5 步质量门禁并给出通过/拒绝判定(任务三)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli pr +view --id <pr_id> --format json +gitlink-cli pr +files --id <pr_id> --format json +gitlink-cli pr +diff --id <pr_id> --format json # 完整 diff +gitlink-cli pr +diff --id <pr_id> --stat # 仅统计摘要 +gitlink-cli ci +builds --owner <owner> --repo <repo> --format json +gitlink-cli ci +log --build <build_number> +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +`gitlink-cli pr +view --id <pr_id> --format json` | `gitlink-cli pr +files --id <pr_id> --format json` | `gitlink-cli pr +diff --id <pr_id> --format json # 完整 diff` | `gitlink-cli pr +diff --id <pr_id> --stat # 仅统计摘要` + diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index d00091d..fc5c98b 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -26,11 +26,7 @@ metadata: | `pr +refuse` | 拒绝并关闭 PR | 是 | | `pr +reopen` | 重开已关闭的 PR | 是 | | `pr +files` | 变更文件列表 | 否 | -| `pr +diff` | 查看变更文件和 diff 内容 | 否 | -| `pr +versions` | 查看 PR patchset/version 列表 | 否 | -| `pr +version-diff` | 查看指定 patchset/version diff | 否 | -| `pr +reviews` | 查看 PR 审查记录,支持状态过滤 | 否 | -| `pr +review` | 创建 PR 审查(支持 dry-run 预览) | 是 | +| `pr +diff` | 查看代码差异(含逐行 diff) | 否 | | `pr +comment` | 给 PR 添加评论 | 是 | ## 使用示例 @@ -61,20 +57,14 @@ gitlink-cli pr +reopen --id 3 # 查看变更文件(含 diff 内容) gitlink-cli pr +files --id 3 -# 查看 PR patchset/version 列表 -gitlink-cli pr +versions --id 3 +# 查看代码差异(逐行 diff) +gitlink-cli pr +diff --id 3 -# 查看指定 patchset/version diff -gitlink-cli pr +version-diff --id 3 --version-id 16040 -gitlink-cli pr +version-diff --id 3 --version-id 16040 --file shortcuts/pr/pr.go +# 查看某个文件的 diff +gitlink-cli pr +diff --id 3 --file src/main.go -# 查看 PR 审查记录(支持按状态过滤) -gitlink-cli pr +reviews --id 3 -gitlink-cli pr +reviews --id 3 --status approved - -# 创建 PR 审查(common/approved/rejected,支持 --dry-run 预览) -gitlink-cli pr +review --id 3 --status approved --content "LGTM, ready to merge" -gitlink-cli pr +review --id 3 --status approved --content "LGTM" --dry-run +# 仅查看 diff 统计摘要 +gitlink-cli pr +diff --id 3 --stat # 给 PR 添加评论 gitlink-cli pr +comment --id 3 --body "LGTM, ready to merge" @@ -166,9 +156,11 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff - **PR 创建要求源分支与目标分支有实际代码差异**,否则返回"分支内容相同,无需创建合并请求" - PR 查看/合并/关闭/重开需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回) - `pr +merge` 默认使用 merge 方式,可通过 `--method` 指定 rebase 或 squash -- `pr +diff` 实际调用 `/pulls/:id/files` 端点,返回变更文件列表和 diff 内容 +- `pr +diff` 调用 `/pulls/:id/files` 端点,返回变更文件列表及每文件的 patch 内容 +- `pr +files` 调用同一 `/pulls/:id/files` 端点,返回变更文件列表(不含 patch 渲染) +- `pr +diff` 支持 `--file` 过滤特定文件和 `--stat` 只看统计摘要 - `pr +versions` / `pr +version-diff` 使用 v1 API,`--id` 为网页 URL `/pulls/N` 中的 PR 序号,`--version-id` 为 patchset/version id - 同一个 PR 分支继续 push 新 commit 会生成新的 patchset/version;正常根据 review 修改代码时,应优先在原 PR 分支继续 push,不要关闭 PR 重开 -- `pr +list` 的 `--state` 参数会映射为 GitLink API 的 `status` 筛选;需要查看所有 PR 时传 `--state all` +- `pr +list` 的 `--state` 参数(open/merged/closed)仅影响统计计数;需要查看所有 PR 时传 `--state all` - PR 状态值:`pull_request_status` 0=open, 1=merged, 2=closed - 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或使用 `issue +comment` 留痕;不要用 Raw API 对 Issue 做不完整更新,否则可能清空 Issue 描述 diff --git a/skills/gitlink-pr/examples/pr-workflow.md b/skills/gitlink-pr/examples/pr-workflow.md new file mode 100644 index 0000000..89ed7b7 --- /dev/null +++ b/skills/gitlink-pr/examples/pr-workflow.md @@ -0,0 +1,96 @@ +# PR 管理完整工作流示例 + +**场景**:贡献者从 Fork 仓库到创建 PR、审查、合并的完整流程。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 已 Fork 目标仓库 + +## 工作流步骤 + +### Step 1:Fork 仓库并创建分支 + +```bash +# Fork 目标仓库 +gitlink-cli repo +fork --owner TargetOrg --repo target-repo + +# Clone 自己的 Fork +git clone https://www.gitlink.org.cn/MyUser/target-repo.git +cd target-repo +git remote add upstream https://www.gitlink.org.cn/TargetOrg/target-repo.git + +# 创建功能分支 +git checkout -b feat/new-search +# ... 编写代码 ... +git add -A && git commit -m "feat: add full-text search" +git push origin feat/new-search +``` + +### Step 2:创建 PR + +```bash +gitlink-cli pr +create \ + --owner TargetOrg --repo target-repo \ + --head MyUser:feat/new-search --base master \ + --title "feat: add full-text search functionality" \ + --body "## 变更说明\n\n- 新增全文搜索功能\n- 支持中英文分词\n- 添加搜索结果高亮\n\n## 测试\n- [x] 单元测试通过\n- [x] 手动验证搜索功能" +``` + +**输出示例:** +```json +{ + "ok": true, + "data": { + "id": 88, + "number": 88, + "title": "feat: add full-text search functionality", + "state": "open", + "source_branch": "MyUser:feat/new-search", + "target_branch": "master" + } +} +``` + +### Step 3:查看和审查 PR + +```bash +# 查看 PR 详情 +gitlink-cli pr +view --id 88 --format json + +# 获取变更文件列表 +gitlink-cli pr +files --id 88 --format json + +# 获取 Diff +gitlink-cli pr +diff --id 88 --format json + +# 添加 Review 评论 +gitlink-cli api POST /TargetOrg/target-repo/pulls/88/reviews --body '{ + "body": "代码结构清晰,测试覆盖完整。建议合并。", + "event": "APPROVE" +}' +``` + +### Step 4:合并 PR + +```bash +# 查看开放的 PR 列表 +gitlink-cli pr +list --state open --format json + +# 合并 PR(使用 squash 方式) +gitlink-cli pr +merge --id 88 --method squash +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli pr +list --state open --format json +gitlink-cli pr +view --id <id> --format json +gitlink-cli pr +files --id <id> --format json +gitlink-cli pr +diff --id <id> --format json +gitlink-cli pr +create --owner <owner> --repo <repo> --head <user>:<branch> --base master --title "..." +gitlink-cli pr +merge --id <id> --method merge +gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}' +``` diff --git a/skills/gitlink-pr/references/gitlink-pr-close.md b/skills/gitlink-pr/references/gitlink-pr-close.md new file mode 100644 index 0000000..1a4ca24 --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-close.md @@ -0,0 +1,47 @@ +# pr +close + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +关闭(拒绝合并)一个 Pull Request。 + +## 命令 + +```bash +# 关闭 PR +gitlink-cli pr +close --id 3 + +# 简写 +gitlink-cli pr +close -i 3 +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id` / `-i` | 是 | PR 序号(`pull_request_number`) | + +## API + +``` +POST /{owner}/{repo}/pulls/{number}/refuse_merge +``` + +## Workflow + +> [!CAUTION] +> This is a **Write Operation** -- confirm user intent before executing. + +1. 使用 `pr +view -i <id>` 确认 PR 状态为 open(`pull_request_status: 0`) +2. 确认用户确实要关闭此 PR(此操作会拒绝合并) +3. 执行 `pr +close -i <id>` + +## 注意事项 + +- 此操作调用 `refuse_merge` 端点,即**拒绝合并** +- 关闭后 PR 状态变为 closed(`pull_request_status: 2`) +- 需要对仓库有相应权限 + +## References + +- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md) -- 认证与全局参数 +- [gitlink-pr SKILL.md](../SKILL.md) -- PR 操作总览 diff --git a/skills/gitlink-pr/references/gitlink-pr-files.md b/skills/gitlink-pr/references/gitlink-pr-files.md index 2daeee8..fdcd139 100644 --- a/skills/gitlink-pr/references/gitlink-pr-files.md +++ b/skills/gitlink-pr/references/gitlink-pr-files.md @@ -1,42 +1,81 @@ -# pr +files +# pr +files / pr +diff > **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 -查看 Pull Request 的变更文件列表及 diff 内容。`pr +diff` 是此命令的别名,行为完全相同。 +查看 Pull Request 的变更文件列表或代码差异。 ## 命令 +### pr +files — 变更文件列表 + +返回 PR 涉及的文件列表,包含文件名、增删行数统计。 + ```bash -# 查看变更文件 gitlink-cli pr +files --id 3 - -# 简写 -gitlink-cli pr +files -i 3 - -# JSON 格式 gitlink-cli pr +files -i 3 --format json +``` -# pr +diff 是等效别名 -gitlink-cli pr +diff -i 3 +### pr +diff — 代码差异(逐行 diff) + +返回 PR 的逐行代码差异,包含每个文件的 sections 和 lines。 + +```bash +# 查看完整 diff +gitlink-cli pr +diff --id 3 + +# 仅查看某个文件的 diff +gitlink-cli pr +diff --id 3 --file src/main.go +gitlink-cli pr +diff --id 3 -f src/main.go + +# 仅查看统计摘要(文件数量、增删行数) +gitlink-cli pr +diff --id 3 --stat ``` ## 参数 +### pr +files + | 参数 | 必填 | 说明 | |------|------|------| | `--id` / `-i` | 是 | PR 序号(`pull_request_number`) | +### pr +diff + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id` / `-i` | 是 | PR 序号(`pull_request_number`) | +| `--file` / `-f` | 否 | 过滤特定文件路径的 diff | +| `--stat` | 否 | 仅显示统计摘要,不显示逐行内容 | + ## API ``` +# +files 使用 GET /{owner}/{repo}/pulls/{number}/files + +# +diff 使用(两步调用) +GET /v1/{owner}/{repo}/pulls/{number}/versions +GET /v1/{owner}/{repo}/pulls/{number}/versions/{version_id}/diff ``` +## +diff 实现说明 + +`pr +diff` 通过两步 API 调用获取完整 diff: + +1. 先调用 `versions` 端点获取 PR 版本列表,取最新版本的 ID +2. 再调用 `versions/{id}/diff` 端点获取该版本的完整 diff 数据 + +返回数据包含每个文件的 `sections` → `lines`,每行有: +- `type` 4: diff hunk header(如 `@@ -10,3 +10,4 @@`) +- `type` 2: 新增行 +- `type` 3: 删除行 + ## 注意事项 -- 返回内容包含变更文件列表和 diff 内容 -- `pr +diff` 与 `pr +files` 调用相同的 API 端点,返回结果一致 -- 适合在 code review 场景中使用,先 `pr +view` 查看概览,再 `pr +files` 查看具体变更 +- `pr +files` 和 `pr +diff` 是**不同命令**:`+files` 返回文件概览,`+diff` 返回逐行差异 +- `pr +diff` 需要两次 API 调用,对大型 PR 可能稍慢 +- 使用 `--stat` 可快速查看变更规模,适合大型 PR +- 适合 code review 流程:先 `pr +view` 查看概览 → `pr +diff --stat` 看规模 → `pr +diff` 看具体变更 ## References diff --git a/skills/gitlink-release-auto/examples/release-auto-workflow.md b/skills/gitlink-release-auto/examples/release-auto-workflow.md new file mode 100644 index 0000000..3080504 --- /dev/null +++ b/skills/gitlink-release-auto/examples/release-auto-workflow.md @@ -0,0 +1,129 @@ +# 自动化 Release 管理完整工作流示例 + +**场景**:项目维护者需要从提交历史自动生成 Release Notes 并发布新版本。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 项目有提交历史和已合并的 PR + +## 工作流步骤 + +### Step 1:确认当前版本和推荐新版本号 + +```bash +# 获取当前最新 Release +gitlink-cli release +list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": { + "releases": [ + { "id": 15, "tag_name": "v1.2.0", "name": "v1.2.0 - 性能优化版" } + ] + } +} +``` + +```bash +# 获取自 v1.2.0 以来的提交历史 +git log v1.2.0..HEAD --format="%H %s %an %ad" --date=short 2>&1 +``` + +### Step 2:AI 分析提交并推荐版本号 + +AI 根据提交类型(Conventional Commits)推荐: + +``` +当前版本:v1.2.0 +分析了 22 次提交: + - 4 个 feat:全文搜索、暗黑模式、消息通知、批量导入 + - 6 个 fix:修复了 6 个 Bug + - 2 个 docs:更新 API 文档 + - 无 Breaking Change + +推荐版本:v1.3.0(MINOR 升级,有新功能) +``` + +### Step 3:自动生成 Release Notes + +```bash +# 获取本期关闭的 Issue +gitlink-cli issue +list --state closed --format json + +# 获取本期合并的 PR +gitlink-cli pr +list --state merged --format json +``` + +AI 根据提交和 PR/Issue 数据生成 Release Notes: + +```markdown +## v1.3.0 (2026-06-12) + +### ✨ 新功能 +- feat(search): 新增全文搜索功能 (#156) +- feat(ui): 支持暗黑模式 (#178) +- feat(notify): 添加站内消息通知 (#189) +- feat(import): 支持批量导入数据 (#195) + +### 🐛 Bug 修复 +- fix(login): 修复手机号登录验证码未清除 (#165) +- fix(upload): 修复大文件上传超时 (#172) +- fix(api): 修复并发请求偶发 500 错误 (#180) + +### 🤝 贡献者 +感谢 @zhangsan、@lisi、@wangwu 的贡献! +``` + +### Step 4:创建 Release 并通知 + +```bash +# 创建 Release +gitlink-cli release +create \ + --owner myorg --repo myproject \ + --tag v1.3.0 \ + --name "v1.3.0 - 功能增强版" \ + --body "## v1.3.0 (2026-06-12) + +### ✨ 新功能 +- feat(search): 新增全文搜索功能 (#156) +- feat(ui): 支持暗黑模式 (#178) + +### 🐛 Bug 修复 +- fix(login): 修复手机号登录验证码未清除 (#165) +- fix(upload): 修复大文件上传超时 (#172) + +### 🤝 贡献者 +感谢 @zhangsan、@lisi 的贡献!" \ + --target master + +# 验证发布成功 +gitlink-cli release +list --owner myorg --repo myproject --format json + +# 通知相关 Issue 已发版 +gitlink-cli issue +comment --number 165 --body "🎉 此问题已在 v1.3.0 中修复,请更新验证。" +gitlink-cli issue +comment --number 172 --body "🎉 此问题已在 v1.3.0 中修复,请更新验证。" +``` + +--- + +## 完整命令速览 + +```bash +# 版本确认 +gitlink-cli release +list --format json +git log v<prev>..HEAD --format="%s" + +# 发布 +gitlink-cli release +create --tag v<x.y.z> --name "..." --body "..." --target master + +# 验证 +gitlink-cli release +list --format json +gitlink-cli release +view --id <version_id> + +# 通知 +gitlink-cli issue +comment --number <n> --body "🎉 已在 v<x.y.z> 修复" +``` diff --git a/skills/gitlink-release/examples/release-workflow.md b/skills/gitlink-release/examples/release-workflow.md new file mode 100644 index 0000000..a3b688b --- /dev/null +++ b/skills/gitlink-release/examples/release-workflow.md @@ -0,0 +1,95 @@ +# Release 管理完整工作流示例 + +**场景**:项目维护者需要创建新版本发布,包含自动生成的 Release Notes。 + +## 前置条件 + +- `gitlink-cli` 已安装并登录 +- 项目有已合并的 PR 和关闭的 Issue + +## 工作流步骤 + +### Step 1:查看当前版本 + +```bash +# 列出已有的 Release +gitlink-cli release +list --owner myorg --repo myproject --format json +``` + +**输出示例:** +```json +{ + "ok": true, + "data": { + "releases": [ + { + "id": 15, + "tag_name": "v1.2.0", + "name": "v1.2.0 - 性能优化版", + "body": "## ✨ 新功能\n...", + "created_at": "2026-05-01" + } + ] + } +} +``` + +### Step 2:查看提交历史和变更 + +```bash +# 查看自上次发版以来的提交 +gitlink-cli repo +commits --owner myorg --repo myproject --limit 30 --format json + +# 查看已合并的 PR +gitlink-cli pr +list --state merged --format json + +# 查看已关闭的 Issue +gitlink-cli issue +list --state closed --format json +``` + +### Step 3:创建 Release + +```bash +gitlink-cli release +create \ + --owner myorg --repo myproject \ + --tag v1.3.0 \ + --name "v1.3.0 - 用户体验升级" \ + --body "## v1.3.0 (2026-06-01) + +### ✨ 新功能 +- feat: 新增搜索历史功能 (#156) +- feat: 支持暗黑模式 (#178) + +### 🐛 Bug 修复 +- fix: 修复登录超时问题 (#165) +- fix: 修复文件上传进度显示错误 (#172) + +### 🤝 贡献者 +感谢 @zhangsan @lisi 的贡献!" \ + --target master +``` + +### Step 4:验证和通知 + +```bash +# 验证发布成功 +gitlink-cli release +list --owner myorg --repo myproject --format json + +# 获取 version_id 用于查看详情 +gitlink-cli release +view --owner myorg --repo myproject --id <version_id> + +# 通知相关 Issue +gitlink-cli issue +comment --number 165 --body "🎉 此问题已在 v1.3.0 中修复,请更新验证。" +gitlink-cli issue +comment --number 172 --body "🎉 此问题已在 v1.3.0 中修复,请更新验证。" +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli release +list --owner <owner> --repo <repo> --format json +gitlink-cli release +view --owner <owner> --repo <repo> --id <version_id> +gitlink-cli release +create --tag v1.3.0 --name "..." --body "..." --target master +gitlink-cli release +delete --id <version_id> +``` diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 5d59705..fa00614 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -38,6 +38,8 @@ metadata: | `repo +fork` | Fork 仓库 | 是 | | `repo +delete` | 删除仓库 | 是 | +> 提交记录、标签列表、原始文件内容等暂未封装 Shortcut,可通过 Raw API 访问(见下方「Raw API 补充」)。 + ## 使用示例 ```bash @@ -80,11 +82,20 @@ gitlink-cli repo +fork --owner Gitlink --repo forgeplus # 删除仓库(⚠️ 危险操作) gitlink-cli repo +delete --owner myuser --repo old-project + +# 查看 README +gitlink-cli repo +readme --owner Gitlink --repo forgeplus + +# 查看贡献者 +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# 查看语言统计 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus ``` ## Raw API 补充 -Shortcuts 未覆盖的仓库操作可用 Raw API: +提交记录、标签列表、原始文件内容等操作暂未封装为 Shortcut,可直接用 Raw API: ```bash # 获取提交列表 @@ -93,8 +104,9 @@ gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20' # 获取标签列表 gitlink-cli api GET /:owner/:repo/tags -# 获取文件内容 +# 获取文件内容(按 ref 指定分支/标签) gitlink-cli api GET /:owner/:repo/raw/main/README.md +gitlink-cli api GET /:owner/:repo/raw/develop/src/main.go ``` ## 注意事项 diff --git a/skills/gitlink-repo/examples/repo-workflow.md b/skills/gitlink-repo/examples/repo-workflow.md new file mode 100644 index 0000000..3e8b9bb --- /dev/null +++ b/skills/gitlink-repo/examples/repo-workflow.md @@ -0,0 +1,64 @@ +# 仓库管理完整工作流示例 + +**场景**:开发者需要创建仓库、查看信息、管理文件和贡献者。 + +## 工作流步骤 + +### Step 1:创建新仓库 + +```bash +gitlink-cli repo +create --name my-project --description "我的新项目" --private false +``` + +### Step 2:查看仓库信息 + +```bash +gitlink-cli repo +info --owner myuser --repo my-project --format json + +# 查看 README +gitlink-cli repo +readme --owner myuser --repo my-project + +# 查看贡献者 +gitlink-cli repo +contributors --owner myuser --repo my-project --format json + +# 查看语言统计 +gitlink-cli repo +languages --owner myuser --repo my-project --format json +``` + +### Step 3:管理仓库文件 + +```bash +# 获取文件内容 +gitlink-cli repo +raw --owner myuser --repo my-project --path README.md + +# 查看目录结构 +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master' + +# 查看提交历史 +gitlink-cli repo +commits --owner myuser --repo myproject --limit 10 --format json +``` + +### Step 4:Fork 和删除 + +```bash +# Fork 仓库 +gitlink-cli repo +fork --owner target-user --repo target-repo + +# 删除自己的仓库(⚠️ 危险操作,需确认) +gitlink-cli repo +delete --owner myuser --repo old-project +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli repo +list --user <user> --format json +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +gitlink-cli repo +create --name <name> --description "..." +gitlink-cli repo +fork --owner <owner> --repo <repo> +gitlink-cli repo +readme --owner <owner> --repo <repo> +gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json +gitlink-cli repo +commits --owner <owner> --repo <repo> --limit <n> --format json +gitlink-cli repo +raw --owner <owner> --repo <repo> --path <path> +``` diff --git a/skills/gitlink-research-fork-impact/examples/research-fork-impact-workflow.md b/skills/gitlink-research-fork-impact/examples/research-fork-impact-workflow.md new file mode 100644 index 0000000..ad42b2d --- /dev/null +++ b/skills/gitlink-research-fork-impact/examples/research-fork-impact-workflow.md @@ -0,0 +1,34 @@ +# gitlink-research-fork-impact · 端到端示例 + +## 场景 +分析科研 fork 的改进方向与影响力,生成想法传播图谱。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +gitlink-cli search +repos -k "<repo_name>" --format json +gitlink-cli pr +list --owner <original_owner> --repo <repo> --state merged format json +gitlink-cli pr +list --state merged --owner <original_owner> --repo <repo> --format json +gitlink-cli pr +list --state open --owner <original_owner> --repo <repo> --format json +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +`gitlink-cli repo +info` | `gitlink-cli search +repos -k "<repo_name>" --format json` | `gitlink-cli pr +list` | `gitlink-cli pr +list --state merged` + diff --git a/skills/gitlink-research-graph/SKILL.md b/skills/gitlink-research-graph/SKILL.md new file mode 100644 index 0000000..f704e88 --- /dev/null +++ b/skills/gitlink-research-graph/SKILL.md @@ -0,0 +1,87 @@ +--- +name: gitlink-research-graph +version: 1.0.0 +description: "科研热点追踪与知识图谱(子赛题四·S2):按一组科研关键词在 GitLink 平台搜索相关仓库,构建「仓库—学者—主题」科研知识图谱(MultiDiGraph),追踪主题热度榜、核心学者与核心团队。当用户要梳理某个研究方向的全景、画知识图谱、找热点/核心学者/团队时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + python: ["scripts/research/requirements.txt"] + cliHelp: "gitlink-cli search +repos --help" + scenario: "S2" +--- + +# gitlink-research-graph — 科研热点追踪与知识图谱 + +> 子赛题四「应用 GitLink 辅助科研」· 场景 **S2 科研热点追踪与知识图谱** + +## 何时使用 + +- 课题组/科研管理者想了解某研究方向(如「深度学习」「知识图谱」)在 GitLink 上的全景:有哪些仓库、哪些活跃学者、哪些是热点主题。 +- 想把「关键词 → 仓库 → 学者/主题」的关系可视化为一张知识图谱(Mermaid / Graphviz)。 +- 为开题、综述、找人合作做主题态势感知。 + +## 前置条件 + +1. 已 `gitlink-cli auth login`(Token 7 天有效)。 +2. 已 `pip install -r scripts/research/requirements.txt`(本场景需 **networkx** 做图谱构建)。 +3. 准备好一组逗号分隔的关键词(如 `"deep learning,nlp,knowledge graph"`)。 + +## 工作流 + +本 Skill 的算法由 `scripts/research/graph_build.py` 实现(Go 出数据 + Python 做建图)。严格分离「取数」与「建图」,便于离线单测: + +1. **取数 `collect()`**:对每个关键词调 `search +repos`(按 `repo_fullname` 去重,取 top N), + 再对每个仓库取 `repo +info` / `repo +contributors` / `repo +languages` / `repo +readme`(前 4000 字符)。 +2. **建图 `build_graph(repos, contributors_map, languages_map, readmes)`**(纯函数,不联网): + 用 `networkx.MultiDiGraph` 建图: + - **节点**:`repo:owner/name`(props 含 language/stars/forks/desc)、`scholar:login`(来自 contributors,过滤 bot/i-robot)、`topic:x`(由 `topics.py` 词典在 description+readme 上抽取)。 + - **边**:`contributes_to`(scholar→repo,weight=contribution_perc 解析为 0~1)、`owns`(scholar→repo 当 author.login==contributor login)、`covers_topic`(repo→topic,weight=出现次数/max)、`collaborates_with`(scholar↔scholar 共享同一 repo)、`related_to`(topic↔topic 在同一 repo 共现)。 +3. **产物**:`graph.json`(结构化,含 scenario/nodes/edges/core_scholars/topic_heat/meta)+ `report.md`(中文趋势报告)+ `graph.mmd`(Mermaid,按 repo/scholar/topic 着色,仅画前 ~40 节点防爆炸)+ `graph.dot`(Graphviz DOT)。 +4. **趋势报告**:主题热度榜(`topics.topic_counter` 在全部 description 上的 top10)+ 核心学者(按出现 repo 数)+ 核心团队。 + +## 命令 + +```bash +# 默认输出到 stdout(JSON) +python scripts/research/graph_build.py --keywords "deep learning,nlp" + +# 输出四件产物到目录 +python scripts/research/graph_build.py --keywords "knowledge graph,gnn" --repos-limit 20 --out ./out + +# 可复现脚本(封装了上述流程) +bash skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh <OWNER_KEYWORDS_EXAMPLE> +# 实际签名:bash knowledge-graph-workflow.sh <KEYWORDS> [OUT_DIR] [REPOS_LIMIT] +``` + +## 输出结构(graph.json) + +```json +{ + "scenario": "S2_research_knowledge_graph", + "keywords": ["deep learning", "nlp"], + "nodes": [{"id":"repo:owner/name","type":"repo","label":"owner/name", + "props":{"language":"Python","stars":120,"forks":30,"description":"..."}}], + "edges": [{"source":"scholar:alice","target":"repo:owner/name","type":"contributes_to","weight":0.6}, + {"source":"repo:owner/name","target":"topic:computer_vision","type":"covers_topic","weight":1.0}], + "core_scholars": [{"login":"alice","repo_count":2}], + "core_teams": ["alice","bob"], + "topic_heat": [{"topic":"deep_learning","count":2}], + "meta": {"keywords":[...], "repo_count":2, "node_count":7, "edge_count":8, + "scholar_count":3, "topic_count":2} +} +``` + +## 验证 + +已在真实科研仓库 **`mindspore-Ecosystem/mindspore`** 所在生态上验证(用关键词 `mindspore` 搜索生态仓库): +图谱正确识别 deep_learning / scientific_computing / nlp / computer_vision 等主题节点, +covers_topic 权重落在 (0,1],contributes_to 权重正确解析 contribution_perc(如 "60%"→0.6), +bot(i-robot)账号被过滤。Mermaid 输出以 `graph TD` 开头、含 repo/scholar/topic 三色 classDef,节点数被截断到 40 以内。 + +单测:`python scripts/research/test_graph_build.py`(17 个用例,不联网,构造 mock 数据喂 `build_graph`)。 + +## 兼容性 + +兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据, +Agent 可直接调上述命令并把 graph.json/graph.mmd 读回做进一步解读与文案化。 +Mermaid 块可被支持 Mermaid 渲染的 Markdown 查看器直接展示;graph.dot 可用 `dot -Tsvg graph.dot -o graph.svg` 渲染。 diff --git a/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh b/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh new file mode 100644 index 0000000..f68fa65 --- /dev/null +++ b/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# knowledge-graph-workflow.sh — S2 科研热点追踪与知识图谱 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一 +# +# 用法: bash knowledge-graph-workflow.sh <KEYWORDS> [OUT_DIR] [REPOS_LIMIT] +# 示例: bash knowledge-graph-workflow.sh "deep learning,nlp" ./out 20 +set -euo pipefail + +KEYWORDS="${1:?用法: $0 <KEYWORDS> [OUT_DIR] [REPOS_LIMIT] 例如: $0 \"deep learning,nlp\" ./out 20}" +OUT_DIR="${2:-./knowledge-graph-output}" +REPOS_LIMIT="${3:-20}" + +# 定位仓库根(脚本位于 skills/gitlink-research-graph/examples/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +GRAPH="$REPO_ROOT/scripts/research/graph_build.py" + +# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值 +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 关键词: $KEYWORDS" +echo "==> CLI: $GITLINK_CLI" +echo "==> 仓库上限: $REPOS_LIMIT" + +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$GRAPH" \ + --keywords "$KEYWORDS" \ + --repos-limit "$REPOS_LIMIT" \ + --out "$OUT_DIR" + +echo +echo "==> 产物:" +ls -1 "$OUT_DIR" +echo +echo "==> 图谱概览:" +python -c " +import json +d=json.load(open('$OUT_DIR/graph.json',encoding='utf-8')) +m=d['meta'] +print(f\"节点: {m['node_count']} (repo={m['repo_count']} scholar={m['scholar_count']} topic={m['topic_count']}) 边: {m['edge_count']}\") +print('Top 主题:', ', '.join(h['topic'] for h in d['topic_heat'][:5])) +print('核心学者:', ', '.join(f\"{s['login']}({s['repo_count']})\" for s in d['core_scholars'][:5])) +" diff --git a/skills/gitlink-research-graph/examples/research-graph-workflow.md b/skills/gitlink-research-graph/examples/research-graph-workflow.md new file mode 100644 index 0000000..85e3cd0 --- /dev/null +++ b/skills/gitlink-research-graph/examples/research-graph-workflow.md @@ -0,0 +1,30 @@ +# gitlink-research-graph · 端到端示例 + +## 场景 +按关键词抓取科研仓库,构建 仓库-学者-主题 知识图谱(S2)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli search +repos --help" +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +`gitlink-cli search +repos --help"` + diff --git a/skills/gitlink-research-insight/SKILL.md b/skills/gitlink-research-insight/SKILL.md new file mode 100644 index 0000000..d7c000e --- /dev/null +++ b/skills/gitlink-research-insight/SKILL.md @@ -0,0 +1,299 @@ +--- +name: gitlink-research-insight +version: 1.0.0 +description: "科研仓库洞悉:场景 S1 仓库级科研项目谱系(lineage)分析 + 四维科研画像(可复现性/活跃度/引用价值/协作健康)。从默认分支提交时间线、合并 PR 演进模式、文档演进、实验/评测文件组织、创新点五个角度回答「这个科研项目怎么一步步长成、值得引用/复现到哪一步」。当用户提到「科研分析」「项目洞悉」「谱系」「lineage」「仓库画像」「可复现性」「引用价值」「科研洞悉」「research insight」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + python: ["scripts/research/requirements.txt"] + cliHelp: "python scripts/research/lineage.py --help" + scenario: "S1" +--- + +# gitlink-research-insight(科研仓库画像) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读分析,不修改任何仓库数据。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。** + +> **前置条件:** 先读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)、[`../gitlink-health/SKILL.md`](../gitlink-health/SKILL.md)(工程健康度指标,本 Skill 在其基础上加科研视角)。 + +--- + +## 子赛题四 · 场景 S1:仓库级科研项目洞悉(lineage 谱系分析) + +> 子赛题四「应用 GitLink 辅助科研」· 场景 **S1 仓库级科研项目洞悉** —— +> 本 Skill 的可复现算法实现,技术栈 = **Go 出数据(gitlink-cli + collect.py)+ Python 做算法(lineage.py)**。 + +### 何时使用(S1) +- 想快速看懂一个陌生科研代码仓库「是怎么一步步长成现在这样的」(提交谱系、合并节奏)。 +- 评估一个开源科研项目「值得引用/复现到哪一步」「哪些合并是关键创新/里程碑」。 +- 为论文综述/相关工作梳理某仓库的演进脉络(提交时间线 + 高影响合并 + 文档演进 + 实验组织)。 + +### 前置条件 +1. 已 `gitlink-cli auth login`(Token 7 天有效)。 +2. 已 `pip install -r scripts/research/requirements.txt`(本场景实际只用标准库,无重型依赖)。 +3. 目标仓库存在且有默认分支提交历史(`repo +info` 能取到 `default_branch`)。 + +### 工作流(S1:lineage.py,Go 出数据 + Python 做算法) +1. **取数**(collect.py 薄封装 gitlink-cli): + - `c.repo_info`(默认分支)、`c.commits(ref=默认分支, max_pages=10)`(提交历史) + - `c.prs(state=merged)`(已合并 PR)、`c.tree` / `c.tree(path='docs')`(仓库树 + 文档树)、`c.readme`。 +2. **算法**(lineage.py 纯函数,已单测覆盖,不联网): + - `is_experiment_file(path)`:命中 experiment*/benchmark*/eval*/tests?/data/ → 科研产物文件。 + - `is_doc_file(path)`:*.md / docs/* → 文档。 + - `build_branch_map(commits, default_branch)`:单分支简化 → `[{name, commits, last_active, is_default}]`。 + - `pr_merge_patterns(merged_prs)`:`[{number, title, status, merged_time, changed_files}]`,按合并时间升序。 + - `doc_evolution(tree)`:docs/*.md 的 `{file, last_date(近似)}`。 + - `innovation_points(merged_prs, commits)`:高影响合并(改文件多 / 合入默认分支 / 含里程碑关键词)→ `[{description, evidence, category}]`。 +3. **产物**: + - `lineage.json`(commit_timeline / branch_map / pr_merge_patterns / doc_evolution / experiment_files / innovation_points) + - `report.md`(中文洞悉报告) + - `branch_graph.mmd`(Mermaid **gitGraph** 分支演进图) + +### 命令(S1) +```bash +# 默认输出到 stdout(JSON) +python scripts/research/lineage.py --owner mindspore-Ecosystem --repo mindspore + +# 输出三件产物到目录 +python scripts/research/lineage.py --owner <OWNER> --repo <REPO> --branches-limit 5 --out ./out + +# 可复现脚本(封装了上述流程) +bash skills/gitlink-research-insight/examples/research-insight-workflow.sh <OWNER> <REPO> [OUT_DIR] +``` + +### 输出结构(lineage.json,S1) +```json +{ + "scenario": "S1_repository_research_insight", + "repo": "owner/repo", "default_branch": "master", + "commit_timeline": [{"date": "2024-05-01", "count": 12}], + "branch_map": [{"name": "master", "commits": 320, "last_active": "2024-06-01", "is_default": true}], + "pr_merge_patterns": [{"number": 2, "title": "...", "status": 1, "merged_time": "2024-06-01", "changed_files": 25}], + "doc_evolution": [{"file": "guide.md", "last_date": "2024-03-01"}], + "experiment_files": ["benchmark/eval.py", "tests/test_model.py"], + "innovation_points": [{"description": "...", "evidence": "PR #2 ...", "category": "大规模重构/新特性"}], + "meta": {"commit_count": 320, "merged_pr_count": 9, "doc_count": 5, "experiment_file_count": 8} +} +``` + +### 验证(S1) +已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(default_branch=master;issue≈20346;PR=9;贡献者=6)验证: +默认分支提交时间线、合并 PR 演进模式、docs 文档清单、benchmark/tests 等实验文件均正确识别; +高影响合并被标为「大规模重构/新特性」或「特性引入」创新点。 + +### 兼容性(S1) +兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据, +Agent 可直接调上述命令并把 `lineage.json` / `report.md` / `branch_graph.mmd` 读回做进一步解读与文案化。 +单测 `python scripts/research/test_lineage.py` 全部离线通过(不联网、不调 gitlink-cli)。 + +--- + + + +## 定位:科研辅助,与 gitlink-health 的分工 + +| Skill | 视角 | 核心问题 | +|-------|------|---------| +| `gitlink-health` | **工程健康度** | 这个项目维护得好不好?(Issue 响应、PR 效率、贡献者活跃) | +| `gitlink-research-insight`(本 Skill) | **科研价值** | 这个开源科研项目**值不值得引用/复现/参与**?(可复现性、引用价值、协作网络) | + +> 本 Skill 服务科研工作者:快速评估一个 GitLink 上的开源科研项目,辅助论文引用、实验复现、合作选择。 + +--- + +## 工作流概览(4 步) + +| 阶段 | 操作 | 命令 | 产出 | +|------|------|------|------| +| ① 数据采集 | fork 检测 + 拉仓库全貌 | `repo +info/+languages/+contributors`、`file +get`、`issue +list`、`git log`(本地兜底) | 原始数据 | +| ② 多维分析 | 按 4 维科研指标评分 | AI 计算(见指标体系) | 各维度得分 | +| ③ 协作图谱 | 构建贡献者协作网络 | AI 从贡献者/PR 数据生成 mermaid | 知识图谱 | +| ④ 生成报告 | 输出科研画像报告 | Markdown 模板 | 评估报告 | + +--- + +## 详细工作流 + +### Step 0:fork 检测(关键,避免给 fork 错评) + +先读 `repo +info` 的 `fork_info` 字段。**若是 fork,引用价值/活跃度应改评 upstream**(fork 数据常为空或滞后,直接评会得出错误结论)。 + +```bash +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +# 看 fork_info.fork_from_name / fork_project_user_login: +# - 为空 → 独立仓库,正常评估 +# - 非空 → 是 fork,记下 fork_project_user_login(upstream owner), +# 后续「引用价值」改评 upstream: +# gitlink-cli repo +info --owner <upstream> --repo <repo> +``` + +### Step 1:采集科研仓库数据 + +> ⚠️ **重要(实测)**:GitLink API 的 `commits`/`tags`/`releases` **列表端点返回 SPA HTML(非 JSON)**,故 `repo +commits`/`+tags`/`+raw` 命令**不存在、也无法补**(没有 JSON 可封装,属平台限制非 CLI 缺陷)。本步骤用「可用命令 + `git` 本地 + `file +get`」组合采集。 + +```bash +# 基础画像(API 可用) +gitlink-cli repo +info --owner <owner> --repo <repo> --format json + # 关键字段:version_releases_count(版本归档)、pull_requests_count、fork_info、contributor_users_count +gitlink-cli repo +languages --owner <owner> --repo <repo> --format json +gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json + # ⚠️ 口径:contributors 返回含纯邮箱提交者(可能 20 条), + # 而 contributor_users_count 只数 GitLink 注册用户(可能 3)—— 巴士因子用 contributors 列表 +gitlink-cli repo +readme --owner <owner> --repo <repo> + +# 合规/复现性文件(用 file +get 读文件内容,替代不存在的 repo +raw) +gitlink-cli file +get --owner <owner> --repo <repo> --path LICENSE +gitlink-cli file +get --owner <owner> --repo <repo> --path .gitea/workflows/ci.yml # 或 .github/workflows/ci.yml + +# 活跃度 / 版本归档(API 无列表 JSON → 用计数 + 本地 git 兜底) +gitlink-cli issue +list --owner <owner> --repo <repo> --format json # Issue 列表可用 +gitlink-cli pr +list --owner <owner> --repo <repo> --format json # ⚠️ 可能因 GitLink quirk 返回空(见降级) +# 版本归档:从 repo +info 的 version_releases_count 判断有无 release(API 取不到 tag 列表) +# 提交/合并历史:需 git clone 后本地分析 +git clone --depth 100 https://gitlink.org.cn/<owner>/<repo>.git /tmp/<repo> 2>/dev/null +git -C /tmp/<repo> log --oneline --since="3 months ago" # 近 3 月提交(活跃度) +git -C /tmp/<repo> tag # 真实 tag 列表(API 取不到) +git -C /tmp/<repo> log --merges --oneline # 已合并 PR(pr +list 空时的兜底) +``` + +**降级方案**(API 取不到数据时): +- `pr +list` 空 → 用 `repo +info` 的 `pull_requests_count`(总数)+ `git log --merges`(本地合并历史) +- `repo +contributor-stats` 报错 → 改用 `repo +contributors`(行数),报告标注「代码行统计不可用」 +- 协作数据全断 → 巴士因子仅用 `contributors` 列表的 `contribution_perc`,报告标注「数据来源:本地 git + contributors」 + +### Step 2:四维科研评分(AI 分析) + +#### 维度 1:可复现性(Reproducibility)—— 科研最核心,满分 10 + +> **工程类 vs 科研数据类适配**:默认按「工程类仓库」(CLI/工具/库)评分。 +> 只有评估**数据科学/实验型项目**(含数据集、实验脚本)时,「数据/数据获取说明」项才适用; +> 工程类项目该项算 **N/A(不计入分母,满分降为 8)**,避免扣冤枉分。 + +| 检查项 | 分值 | 判定 | +|--------|:---:|------| +| 有 CI 配置 | +2 | `.gitea/workflows` 或 `.github/workflows` 存在 | +| 依赖锁定文件 | +2 | `go.sum` / `package-lock.json` / `requirements.txt` 等存在 | +| 数据/数据获取说明 | +2 | README 提及数据集,或有 `data/` 目录 | +| 运行/环境文档 | +2 | README 有安装、运行、环境要求说明 | +| 版本归档 | +2 | 有 release 或 tag(可引用特定版本) | + +#### 维度 2:活跃度(Activity) +- 近 3 个月提交频率(`git log --since="3 months ago"` 本地;API 无 commits JSON 端点) +- Issue/PR 近期活跃数(`issue +list` + `pull_requests_count`) +- 贡献者增长趋势(`repo +contributors` 的时间分布,本地 git) + +#### 维度 3:引用价值(Citation-worthiness) +- 有 LICENSE(开源协议清晰) +- 有版本归档(可引用固定版本,科研刚需) +- 文档完整(README/Wiki) +- 星标/关注量 + +#### 维度 4:协作健康(Collaboration) +- Issue 平均响应时长 +- PR 合并率 +- **巴士因子**:核心贡献者提交占比(越分散越健康,集中度高=高风险) +- 贡献者协作网络(见 Step 3) + +### Step 3:协作知识图谱 + +AI 从贡献者列表 + PR 协作数据,生成 mermaid 协作网络图: + +```mermaid +graph LR + subgraph 核心["核心贡献者"] + A[alice] + B[bob] + end + C[carol] + D[dave] + P((<repo>)) + A -->|主提交| P + B -->|主提交| P + C -->|偶发贡献| P + D -->|偶发贡献| P + A -.评审.-> C + B -.评审.-> D +``` + +> 协作密集度 + 核心圈识别 = 科研合作潜力评估。 + +### Step 4:科研画像报告(见输出模板) + +--- + +## 输出模板:科研仓库画像报告 + +```markdown +# 🔬 科研仓库画像 — <owner>/<repo> + +> 一句话定性:<这是一个 [活跃维护/停滞/新兴] 的 [领域] 科研项目,[适合/谨慎/不建议] 引用与复现。> + +## 📊 综合评分:<⭐ x/10> + +| 维度 | 得分 | 评价 | +|------|:---:|------| +| 🔁 可复现性 | <x>/10 | <能否复现实验结果> | +| 📈 活跃度 | <x>/10 | <近期维护状态> | +| 📑 引用价值 | <x>/10 | <是否适合论文引用> | +| 🤝 协作健康 | <x>/10 | <社区协作质量> | + +## 📋 基础信息 +| 项 | 值 | +|----|-----| +| 描述 | <description> | +| 主要语言 | <language> | +| 许可证 | <license> | +| 贡献者数 | <n> | +| 版本归档 | <有/无,最新 tag> | + +## 🔁 可复现性详情(科研核心) +- CI 配置:<✅ 有 / ❌ 无> +- 依赖锁定:<✅ go.sum / ❌ 无> +- 数据说明:<✅ 有 / ❌ 无> +- 运行文档:<✅ 完整 / ⚠️ 部分 / ❌ 无> +- 版本归档:<✅ v1.x / ❌ 无> +> 可复现性结论:<他人能否独立跑出同样结果> + +## 🤝 协作网络图 +<mermaid 协作图谱> +- 巴士因子:<核心贡献者提交占比 X%,<健康/集中风险>> + +## 💡 给科研工作者的建议 +1. **引用**:<建议引用最新 tag vX.Y,协议 XXX / 谨慎:无明确版本> +2. **复现**:<按 README + CI 可复现 / 需补充环境说明> +3. **合作**:<协作网络健康,可联系 @核心贡献者 / 核心圈封闭,贡献门槛高> + +--- +*由 gitlink-research-insight 科研画像自动生成* +``` + +--- + +## 决策规则 + +| 场景 | 处理 | +|------|------| +| 用户说"分析这个科研项目值不值得用" | 跑完整 4 步,重点出可复现性 + 引用价值 | +| 用户只关心能否复现 | 聚焦维度 1(可复现性),输出 5 项检查清单 | +| 用户要做领域综述 | 建议对多个仓库重复本流程,汇总对比(衔接方向 B 热点追踪) | +| 仓库无 LICENSE / 无 README | 可复现性和引用价值直接扣分,报告标注风险 | +| 巴士因子 < 0.5(核心贡献者 >50% 提交) | 协作健康标 ⚠️ 单点风险 | + +--- + +## 注意事项 + +- **纯只读**:本 Skill 只采集和分析,不改任何数据 +- **科研视角**:区别于 health 的工程视角,重点在可复现性/引用价值/协作网络 +- **协作图谱**:mermaid 图基于贡献者 + PR 协作关系,AI 从数据推断 +- **评分主观性**:评分体系透明可调,AI 给分需附判定依据(哪项 +分) +- **真实仓库验证**:选 GitLink 上一个科研类仓库(有 LICENSE/CI/数据的项目最佳)演示 +- **可复现**:配套 `research-insight-workflow.sh` 脚本可端到端复现 + +## References + +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证与全局参数 +- [gitlink-health](../gitlink-health/SKILL.md) — 工程健康度(本 Skill 复用其指标并加科研视角) +- [gitlink-repo](../gitlink-repo/SKILL.md) / [gitlink-issue](../gitlink-issue/SKILL.md) / [gitlink-pr](../gitlink-pr/SKILL.md) — 数据采集命令 diff --git a/skills/gitlink-research-insight/examples/research-insight-workflow.md b/skills/gitlink-research-insight/examples/research-insight-workflow.md new file mode 100644 index 0000000..22649fa --- /dev/null +++ b/skills/gitlink-research-insight/examples/research-insight-workflow.md @@ -0,0 +1,35 @@ +# gitlink-research-insight · 端到端示例 + +## 场景 +四维科研评分(可复现性/活跃度/引用价值/协作健康)+ 协作图谱(S1)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli repo +info --owner <owner> --repo <repo> --format json +gitlink-cli repo +languages --owner <owner> --repo <repo> --format json +gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json +gitlink-cli repo +readme --owner <owner> --repo <repo> +gitlink-cli file +get --owner <owner> --repo <repo> --path LICENSE +gitlink-cli issue +list --owner <owner> --repo <repo> --format json # Issue 列表可用 +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +`gitlink-cli repo +info` | `gitlink-cli repo +languages` | `gitlink-cli repo +contributors` | `gitlink-cli repo +readme` + diff --git a/skills/gitlink-research-insight/examples/research-insight-workflow.sh b/skills/gitlink-research-insight/examples/research-insight-workflow.sh new file mode 100644 index 0000000..8691cd5 --- /dev/null +++ b/skills/gitlink-research-insight/examples/research-insight-workflow.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# research-insight-workflow.sh — S1 仓库级科研项目洞悉 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一(Go 出数据 + Python 做算法) +# +# 用法: bash research-insight-workflow.sh <OWNER> <REPO> [OUT_DIR] [BRANCHES_LIMIT] +# 示例: bash research-insight-workflow.sh mindspore-Ecosystem mindspore ./out 5 +set -euo pipefail + +OWNER="${1:?用法: $0 <OWNER> <REPO> [OUT_DIR] [BRANCHES_LIMIT]}" +REPO="${2:?缺少 REPO}" +OUT_DIR="${3:-./research-insight-output}" +BRANCHES_LIMIT="${4:-5}" + +# 定位仓库根(脚本位于 skills/gitlink-research-insight/examples/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LINEAGE="$REPO_ROOT/scripts/research/lineage.py" + +# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值 +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 目标仓库: $OWNER/$REPO" +echo "==> CLI: $GITLINK_CLI" +echo "==> 分支地图上限=$BRANCHES_LIMIT" + +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$LINEAGE" \ + --owner "$OWNER" --repo "$REPO" \ + --branches-limit "$BRANCHES_LIMIT" \ + --out "$OUT_DIR" + +echo +echo "==> 产物:" +ls -1 "$OUT_DIR" +echo +echo "==> 洞悉摘要预览:" +python -c " +import json +d=json.load(open('$OUT_DIR/lineage.json',encoding='utf-8')) +m=d['meta'] +print(f\"默认分支: {d['default_branch']} | 提交 {m['commit_count']} 条 | 合并 PR {m['merged_pr_count']} 个\") +print(f\"文档 {m['doc_count']} 个 | 实验/评测文件 {m['experiment_file_count']} 个\") +print('创新/里程碑点:') +for i,it in enumerate(d['innovation_points'][:8],1): + print(f\" {i}. [{it['category']}] {it['description']}\") +" diff --git a/skills/gitlink-research-progress/SKILL.md b/skills/gitlink-research-progress/SKILL.md new file mode 100644 index 0000000..9f8e60d --- /dev/null +++ b/skills/gitlink-research-progress/SKILL.md @@ -0,0 +1,72 @@ +--- +name: gitlink-research-progress +version: 1.0.0 +description: "科研进度智能跟踪与预警(子赛题四·S5):统计科研仓库本周/上周的提交、Issue、PR 活跃度,结合里程碑进度,用阈值规则产出风险预警(stale issue / stale PR / 逾期里程碑 / 低活跃 / bus factor),生成科研进度周报。当用户要做项目周报、进度跟踪、风险预警、里程碑监控时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "python scripts/research/report.py --help" + scenario: "S5" +--- + +# gitlink-research-progress — 科研进度智能跟踪与预警 + +> 子赛题四「应用 GitLink 辅助科研」· 场景 **S5 科研进度智能跟踪与预警** + +## 何时使用 + +- 课题组负责人想每周自动生成「科研项目进度周报」。 +- 想及时发现项目停滞(低活跃)、Issue/PR 堆积、里程碑逾期、单点依赖(bus factor)。 +- 为科研项目做里程碑监控与风险预警。 + +## 前置条件 + +1. 已 `gitlink-cli auth login`。 +2. 本场景为纯标准库实现,无需额外 pip 依赖(`scripts/research/report.py`)。 + +## 工作流 + +算法由 `scripts/research/report.py` 实现(Go 出数据 + Python 做统计/预警): + +1. **取数**:`commits`(Raw API)/ `issue +list --state all` / `pr +list --state all` / `milestone +list` / `repo +contributors`。 +2. **周统计**:按时间窗口把提交/Issue/PR 划入「本周 [now-7d, now]」与「上周 [now-14d, now-7d)」,统计新增/关闭/stale/活跃贡献者。 +3. **里程碑进度**:按 `milestone_name` 归集 Issue 的 open/closed,算完成率与逾期。 +4. **风险预警**(阈值规则): + - `low_activity`:本周提交 < 3 + - `bus_factor`:单一贡献者占本周提交 > 50% 且活跃贡献者 ≤ 2(critical) + - `stale_issue`:开放 Issue 超 30 天无活动(≥5 触发,≥20 升级 critical) + - `stale_pr`:开放 PR 超 14 天未 review(≥1 触发) + - `overdue_milestone`:未关闭里程碑已过 due_date(critical) +5. **趋势**:本周 vs 上周 commit 环比,给出 increasing/stable/decreasing。 +6. **产物**:`report.json`(结构化)+ `weekly_report.md`(中文周报:活动对比表 + 里程碑表 + 风险预警表)。 + +## 命令 + +```bash +python scripts/research/report.py --owner mindspore-Ecosystem --repo mindspore --out ./out +python scripts/research/report.py --owner O --repo R # 仅打印 JSON + +# 可复现脚本 +bash skills/gitlink-research-progress/examples/progress-report-workflow.sh <OWNER> <REPO> [OUT_DIR] +``` + +## 输出结构(report.json) + +```json +{ + "scenario": "S5_progress_tracking", "repo": "owner/repo", + "week_stats": {"this_week": {...}, "last_week": {...}, "window": {...}}, + "trend": {"commit_delta_pct": 12.5, "activity_level": "increasing"}, + "milestones": [{"name":"v1.0","open":2,"closed":1,"completion_pct":33.3,"overdue":true}], + "risk_warnings": [{"level":"critical","type":"bus_factor","message":"...","suggestion":"..."}] +} +``` + +## 验证 + +已在真实科研仓库 **`mindspore-Ecosystem/mindspore`** 上验证取数与统计口径; +11 个纯单元测试覆盖时间解析、周分桶、stale 判定、bus factor、里程碑逾期、趋势计算。 + +## 兼容性 + +兼容 Claude Code 等 AI Agent:本 SKILL.md 为编排依据,Agent 调上述命令并把周报读回做解读与跟进建议。 diff --git a/skills/gitlink-research-progress/examples/progress-report-workflow.sh b/skills/gitlink-research-progress/examples/progress-report-workflow.sh new file mode 100644 index 0000000..b128c45 --- /dev/null +++ b/skills/gitlink-research-progress/examples/progress-report-workflow.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# progress-report-workflow.sh — S5 科研进度周报 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一 +# +# 用法: bash progress-report-workflow.sh <OWNER> <REPO> [OUT_DIR] +# 示例: bash progress-report-workflow.sh mindspore-Ecosystem mindspore ./out +set -euo pipefail + +OWNER="${1:?用法: $0 <OWNER> <REPO> [OUT_DIR]}" +REPO="${2:?缺少 REPO}" +OUT_DIR="${3:-./progress-output}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +REPORT="$REPO_ROOT/scripts/research/report.py" + +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 目标仓库: $OWNER/$REPO (CLI: $GITLINK_CLI)" +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$REPORT" --owner "$OWNER" --repo "$REPO" --out "$OUT_DIR" + +echo +echo "==> 产物:"; ls -1 "$OUT_DIR" +echo +echo "==> 摘要:" +python -c " +import json +d=json.load(open('$OUT_DIR/report.json',encoding='utf-8')) +tw=d['week_stats']['this_week']; tr=d['trend'] +print(f\"本周提交 {tw['commits']},活跃贡献者 {tw['contributors_active']},趋势 {tr['activity_level']}({tr['commit_delta_pct']}%)\") +print(f\"风险预警 {len(d['risk_warnings'])} 条:\") +for w in d['risk_warnings']: + print(f\" [{w['level']}] {w['type']}: {w['message']}\") +" diff --git a/skills/gitlink-research-progress/examples/research-progress-workflow.md b/skills/gitlink-research-progress/examples/research-progress-workflow.md new file mode 100644 index 0000000..a22ea29 --- /dev/null +++ b/skills/gitlink-research-progress/examples/research-progress-workflow.md @@ -0,0 +1,30 @@ +# gitlink-research-progress · 端到端示例 + +## 场景 +监控科研仓库提交/Issue/里程碑,生成进度周报 + 风险预警(S5)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +该 Skill 为 AI 工作流型,在 Claude Code 里用自然语言触发效果最佳: + +> 「读 skills/gitlink-research-progress/SKILL.md,<具体任务描述>」 + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +触发语 → AI 读 `skills/gitlink-research-progress/SKILL.md` 编排 + diff --git a/skills/gitlink-research-visual/SKILL.md b/skills/gitlink-research-visual/SKILL.md new file mode 100644 index 0000000..dea9737 --- /dev/null +++ b/skills/gitlink-research-visual/SKILL.md @@ -0,0 +1,84 @@ +--- +name: gitlink-research-visual +version: 1.0.0 +description: "科研成果可视化沉淀(子赛题四·S6):把一个科研仓库的开发时间线(commit/issue/pr 周粒度趋势)、贡献者×周热力图、语言占比饼图、里程碑甘特沉淀成一张可交互的 HTML 报告,并从 README/提交信息中抽取论文引用(arXiv/DOI)、按目录分类仓库产物(论文/数据集/模型/基准)。当用户提到「科研成果可视化」「可视化沉淀」「开发节奏」「贡献热力图」「论文引用抽取」「产物分类」「research visualization」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + python: ["scripts/research/requirements.txt"] + cliHelp: "gitlink-cli api --help" + scenario: "S6" +--- + +# gitlink-research-visual — 科研成果可视化沉淀 + +> 子赛题四「应用 GitLink 辅助科研」· 场景 **S6 科研成果可视化沉淀** + +## 何时使用 + +- 想把一个科研代码仓库的「成果产出」沉淀成可视化报告(给导师/合作者/项目主页展示)。 +- 想看开发节奏:提交/Issue/PR 的周粒度趋势、贡献者活跃热力图。 +- 想自动整理一份仓库里「能被引用的源头」清单:论文 arXiv/DOI 链接、数据集/模型/基准产物。 +- 给开源科研项目做年度总结、阶段性汇报、成果网页的数据底座。 + +## 前置条件 + +1. 已 `gitlink-cli auth login`(Token 7 天有效)。 +2. 已 `pip install -r scripts/research/requirements.txt`;本场景核心算法仅用标准库, + 生成交互 HTML 需 `plotly>=5.18`(已列入 requirements)。**未安装 plotly 时优雅降级**:仅写 `visual.json` + `report.md` 并提示。 +3. 目标仓库存在且有提交历史(时间线/热力图的数据来源)。 + +## 工作流 + +本 Skill 的算法由 `scripts/research/visual.py` 实现(Go 出数据 + Python 做可视化): + +1. **取数**(`collect()`):`commits`(Raw API 分页,取够 ~weeks 周,max_pages=10)+ `issue +list` + `pr +list` + `milestone +list` + `repo +languages` + `repo +contributors` + `repo +readme` + `repo +tree`。 +2. **周分桶**(`bin_weekly`):把 commit/issue/pr 按时间戳对齐到 ISO 周,分入「最近 weeks 周」的桶里,得到时间线趋势数据。 +3. **贡献热力图**(`contribution_heatmap`):top 贡献者 × 周桶的提交数矩阵,定位核心贡献者与活跃周期。 +4. **论文引用抽取**(`extract_paper_links`):正则匹配 arXiv(`arxiv.org/abs|pdf/`、`arXiv:`)与 DOI(`doi.org/`、裸 `10.xxxx/`),去重并带上下文片段。 +5. **产物分类**(`classify_artifacts`):按仓库目录路径把文件归入 paper / dataset / model / benchmark 四类。 +6. **渲染**:单一交互 HTML(plotly 多子图:时间线折线 + 贡献热力 + 语言饼图 + 里程碑甘特),同时输出原始数据 bundle `visual.json`(供网页前端二次开发)+ `report.md`(中文摘要)。 + +## 命令 + +```bash +# 默认输出到 stdout(JSON) +python scripts/research/visual.py --owner mindspore-Ecosystem --repo mindspore + +# 输出可视化三件套到目录(visual.html + visual.json + report.md) +python scripts/research/visual.py --owner <OWNER> --repo <REPO> --weeks 26 --out ./out + +# 可复现脚本(封装了上述流程) +bash skills/gitlink-research-visual/examples/research-visual-workflow.sh <OWNER> <REPO> [OUT_DIR] [WEEKS] +``` + +## 输出结构(visual.json) + +```json +{ + "scenario": "S6_research_visualization", + "repo": "owner/repo", + "weeks": 26, + "timeline": {"labels": ["2026-W01", "..."], "commits": [...], "issues": [...], "prs": [...]}, + "heatmap": {"users": ["alice", "..."], "weeks": [...], "matrix": [[0,1,...], ...]}, + "languages": {"Python": "99.7%"}, + "milestones": [{"title": "v1.0", "start": 1718000000, "due": 1720000000}], + "paper_links": [{"source_text_snippet": "see arxiv...", "target": "https://arxiv.org/abs/2401.00012", "type": "arxiv"}], + "artifacts": [{"path": "data/train.csv", "category": "dataset", "name": "train.csv"}], + "artifact_summary": {"paper": 3, "dataset": 5, "model": 2, "benchmark": 1}, + "meta": {"commit_count": 800, "issue_count": 20346, "pr_count": 9, "milestone_count": 4, "contributor_count": 6} +} +``` + +## 验证 + +已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(default_branch=master;issue≈20346;PR=9;贡献者=6)上验证: +周分桶正确对齐 ISO 周(无 plotly 时优雅降级为 `visual.json` + `report.md`); +时间线/热力图数据由 `bin_weekly`/`contribution_heatmap` 纯函数产出,离线单测 `python scripts/research/test_visual.py` 全部通过(33 个用例,覆盖周分桶/热力矩阵/论文链接抽取/产物分类)。 + +## 兼容性 + +兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据, +Agent 可直接调上述命令并把产物(HTML/JSON/Markdown)读回做进一步解读与文案化。 +纯函数算法(`bin_weekly`/`contribution_heatmap`/`extract_paper_links`/`classify_artifacts`)与取数层解耦, +可在不联网、不调 gitlink-cli 的前提下被单测与复用。 diff --git a/skills/gitlink-research-visual/examples/research-visual-workflow.md b/skills/gitlink-research-visual/examples/research-visual-workflow.md new file mode 100644 index 0000000..45997c1 --- /dev/null +++ b/skills/gitlink-research-visual/examples/research-visual-workflow.md @@ -0,0 +1,30 @@ +# gitlink-research-visual · 端到端示例 + +## 场景 +把科研项目时间线/热力/甘特/语言占比沉淀成交互可视化(S6)。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +该 Skill 为 AI 工作流型,在 Claude Code 里用自然语言触发效果最佳: + +> 「读 skills/gitlink-research-visual/SKILL.md,<具体任务描述>」 + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +工作流型 Skill 的输出符合其 SKILL.md「输出模板」(含评分/判定/报告关键字段)。 + +## 命令速览 + +触发语 → AI 读 `skills/gitlink-research-visual/SKILL.md` 编排 + diff --git a/skills/gitlink-research-visual/examples/research-visual-workflow.sh b/skills/gitlink-research-visual/examples/research-visual-workflow.sh new file mode 100644 index 0000000..742ae63 --- /dev/null +++ b/skills/gitlink-research-visual/examples/research-visual-workflow.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# research-visual-workflow.sh — S6 科研成果可视化沉淀 · 可复现执行脚本 +# 子赛题四「应用 GitLink 辅助科研」交付物之一 +# +# 用法: bash research-visual-workflow.sh <OWNER> <REPO> [OUT_DIR] [WEEKS] +# 示例: bash research-visual-workflow.sh mindspore-Ecosystem mindspore ./out 26 +set -euo pipefail + +OWNER="${1:?用法: $0 <OWNER> <REPO> [OUT_DIR] [WEEKS]}" +REPO="${2:?缺少 REPO}" +OUT_DIR="${3:-./research-visual-output}" +WEEKS="${4:-26}" + +# 定位仓库根(脚本位于 skills/gitlink-research-visual/examples/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +VISUAL="$REPO_ROOT/scripts/research/visual.py" + +# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值 +: "${GITLINK_CLI:=gitlink-cli}" + +echo "==> 目标仓库: $OWNER/$REPO" +echo "==> CLI: $GITLINK_CLI" +echo "==> 回溯周数: $WEEKS" + +mkdir -p "$OUT_DIR" +GITLINK_CLI="$GITLINK_CLI" python "$VISUAL" \ + --owner "$OWNER" --repo "$REPO" \ + --weeks "$WEEKS" \ + --out "$OUT_DIR" + +echo +echo "==> 产物:" +ls -1 "$OUT_DIR" +echo +echo "==> 成果概览:" +python -c " +import json +d=json.load(open('$OUT_DIR/visual.json',encoding='utf-8')) +m=d['meta']; a=d['artifact_summary'] +print(f\"仓库: {d['repo']} 回溯 {d['weeks']} 周\") +print(f\"活跃度: commits={m['commit_count']} issues={m['issue_count']} prs={m['pr_count']} 贡献者={m['contributor_count']}\") +print(f\"产物分类: paper={a['paper']} dataset={a['dataset']} model={a['model']} benchmark={a['benchmark']}\") +print(f\"抽取论文引用: {len(d['paper_links'])} 条\") +tl=d['timeline'] +peak=max(tl['commits']) if tl['commits'] else 0 +print(f\"开发节奏(最近窗口): 峰值 {peak} 提交/周\") +" diff --git a/skills/gitlink-scholar-profile/examples/scholar-profile-workflow.md b/skills/gitlink-scholar-profile/examples/scholar-profile-workflow.md new file mode 100644 index 0000000..d5ba23c --- /dev/null +++ b/skills/gitlink-scholar-profile/examples/scholar-profile-workflow.md @@ -0,0 +1,33 @@ +# gitlink-scholar-profile · 端到端示例 + +## 场景 +跨仓库聚合学者/团队科研产出,生成影响力雷达 + 代表性成果。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli user +info --login <user_input> --format json +gitlink-cli search +users -k <user_input> --format json +gitlink-cli user +info --login <resolved_login> --format json +gitlink-cli org +info --login <user_input> --format json +gitlink-cli search +repos -k <user_input> --format json +gitlink-cli user +info --login <login> --format json +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli user +info --login <user_input> --format json` | `gitlink-cli search +users -k <user_input> --format json` | `gitlink-cli user +info --login <resolved_login> --format json` | `gitlink-cli org +info --login <user_input> --format json` + diff --git a/skills/gitlink-search/SKILL.md b/skills/gitlink-search/SKILL.md index a683e99..68dfbac 100644 --- a/skills/gitlink-search/SKILL.md +++ b/skills/gitlink-search/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-search -version: 1.0.0 -description: "搜索:按关键词搜索仓库和用户。当用户需要在 GitLink 上查找特定仓库、搜索某个具体项目或用户名时触发。" +version: 1.1.0 +description: "搜索:搜索仓库、用户和 Issue。当用户需要在 GitLink 上查找特定仓库、搜索某个项目、用户名或 Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,6 +22,7 @@ metadata: |----------|------| | `search +repos` | 搜索仓库 | | `search +users` | 搜索用户 | +| `search +issues` | 搜索 Issue(需要 owner/repo) | ## 使用示例 @@ -31,4 +32,16 @@ gitlink-cli search +repos --keyword "machine learning" --limit 10 # 搜索用户 gitlink-cli search +users --keyword "zhangsan" + +# 搜索 Issue(基本用法) +gitlink-cli search +issues --owner MyOrg --repo my-project --keyword "登录失败" + +# 搜索已关闭的 Issue +gitlink-cli search +issues -k "bug" --category closed + +# 搜索指定负责人和标签的 Issue +gitlink-cli search +issues -k "性能" --assignee 42 --tag 1,2 + +# 按创建时间正序排列 +gitlink-cli search +issues -k "需求" --sort-by created_on --sort-dir asc ``` diff --git a/skills/gitlink-search/examples/search-workflow.md b/skills/gitlink-search/examples/search-workflow.md new file mode 100644 index 0000000..255719f --- /dev/null +++ b/skills/gitlink-search/examples/search-workflow.md @@ -0,0 +1,59 @@ +# 搜索操作完整工作流示例 + +**场景**:开发者需要在 GitLink 上搜索仓库、用户和 Issue。 + +## 工作流步骤 + +### Step 1:搜索仓库 + +```bash +gitlink-cli search +repos --keyword "machine learning" --limit 10 +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { + "full_name": "AI-Lab/ml-framework", + "description": "轻量级机器学习框架", + "language": "Python", + "forks_count": 156, + "stars_count": 1024 + } + ] +} +``` + +### Step 2:搜索用户 + +```bash +gitlink-cli search +users --keyword "zhangsan" +``` + +### Step 3:搜索 Issue + +```bash +# 基本搜索 +gitlink-cli search +issues --owner myorg --repo myproject --keyword "登录失败" + +# 搜索已关闭的 Issue +gitlink-cli search +issues --keyword "bug" --category closed + +# 按标签和负责人筛选 +gitlink-cli search +issues --keyword "性能" --assignee 42 --tag 1,2 + +# 按时间排序 +gitlink-cli search +issues --keyword "需求" --sort-by created_on --sort-dir asc +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli search +repos --keyword "<keyword>" --limit <n> +gitlink-cli search +users --keyword "<username>" +gitlink-cli search +issues --owner <owner> --repo <repo> --keyword "<keyword>" --category opened +``` diff --git a/skills/gitlink-search/references/gitlink-search-issues.md b/skills/gitlink-search/references/gitlink-search-issues.md new file mode 100644 index 0000000..d1b63e7 --- /dev/null +++ b/skills/gitlink-search/references/gitlink-search-issues.md @@ -0,0 +1,52 @@ +# search +issues + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +搜索指定仓库中的 Issue(疑修)。支持关键词搜索、状态筛选、负责人/作者/里程碑/标签过滤、排序等。 + +## 命令 + +```bash +# 基本搜索 +gitlink-cli search +issues --owner MyOrg --repo my-project --keyword "登录失败" + +# 简写 +gitlink-cli search +issues -k "bug" --owner MyOrg --repo my-project + +# JSON 格式输出 +gitlink-cli search +issues -k "bug" --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--keyword` / `-k` | 是 | 搜索关键词 | +| `--category` / `-c` | 否 | Issue 类型:`all`(全部)、`opened`(开启中)、`closed`(已关闭),默认 `all` | +| `--assignee` / `-a` | 否 | 按负责人用户 ID 筛选 | +| `--author` | 否 | 按创建人用户 ID 筛选 | +| `--milestone` / `-m` | 否 | 按里程碑 ID 筛选 | +| `--tag` / `-t` | 否 | 按标签 ID 筛选(多个 ID 用逗号分隔) | +| `--sort-by` | 否 | 排序字段:`updated_on`(默认)、`created_on`、`priority` | +| `--sort-dir` | 否 | 排序方向:`desc`(默认,倒序)、`asc`(正序) | +| `--page` / `-p` | 否 | 页码,默认 1 | +| `--limit` / `-l` | 否 | 每页数量,默认 20 | + +## API + +``` +GET /api/v1/{owner}/{repo}/issues?keyword=xxx&category=opened&page=1&limit=20 +``` + +## 注意事项 + +- 搜索 Issue 需要指定 `--owner` 和 `--repo`(或在一个已配置 git remote 的仓库目录中运行) +- `--tag` 参数接受逗号分隔的标签 ID(如 `--tag 1,2,3`),而非标签名称 +- `--assignee` 和 `--author` 接受用户 ID(数字),不是用户名 +- 返回数据包含 `total_count`、`opened_count`、`closed_count` 和 `issues` 数组 +- 每个 Issue 包含 `project_issues_index`(网页 URL 中的序号)、`subject`、`status_name`、`author`、`assigners` 等字段 + +## References + +- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md) -- 认证与全局参数 +- [gitlink-search SKILL.md](../SKILL.md) -- 搜索操作总览 diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index 55674de..63390e2 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -110,6 +110,10 @@ gitlink-cli auth login | PR 合并需要 `do` 参数 | `pr +merge` 需传 `do` 字段指定合并方式(merge/rebase/squash) | `pr +merge` 已内置处理 | | PR 列表 state 过滤 | `--state` 参数仅影响统计计数,返回列表可能包含所有状态 | 需通过 `pull_request_status` 字段客户端过滤:0=open, 1=merged, 2=closed | | PR 创建需要代码差异 | 分支内容必须与目标分支不同,否则拒绝创建 | 需要先在分支上有实际提交 | +| **PR 列表可能返回空** | 部分仓库(如 fork、权限受限)`pr +list` 返回 `pulls:[]`,但 `repo +info` 的 `pull_requests_count` 非零 | 平台 quirk;用 `pull_requests_count`(总数)+ `git log --merges`(本地合并历史)兜底 | +| **repo +contributor-stats 报错** | `repo +contributor-stats` 可能返回「获取贡献者(代码行)失败」 | API 不稳;改用 `repo +contributors`(含行数,但口径含纯邮箱提交者) | +| **commits/tags/releases 无 JSON API** | 这些列表端点返回 SPA HTML(非 JSON),故**无 `repo +commits`/`+tags`/`+raw` 命令** | 平台未开放;提交/标签历史用 `git clone`+`git log`/`git tag` 本地获取;读文件内容用 `file +get` 替代 `repo +raw` | +| **贡献者口径不一致** | `repo +info` 的 `contributor_users_count` 只数 GitLink 注册用户;`repo +contributors` 返回含纯邮箱提交者(数量更多) | 巴士因子/协作分析用 `repo +contributors` 列表;「注册贡献者数」用 `contributor_users_count` | ## 文件操作 API diff --git a/skills/gitlink-snippet/SKILL.md b/skills/gitlink-snippet/SKILL.md new file mode 100644 index 0000000..1240dae --- /dev/null +++ b/skills/gitlink-snippet/SKILL.md @@ -0,0 +1,190 @@ +--- +name: gitlink-snippet +version: 1.0.0 +description: "本地代码片段管理:创建、查看、搜索、更新、删除、导出个人代码片段库。当用户需要保存/查找常用代码片段、代码片段管理、代码片段检索时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli snippet --help" +--- + +# gitlink-snippet(本地代码片段管理) + +**CRITICAL — 所有删除操作(`snippet +delete`)前必须确认用户意图,删除不可恢复。** +**CRITICAL — 本技能为本地功能,不调用 GitLink 平台 API、不需要登录认证。** +**CRITICAL — `--content -` 表示从 stdin 读取内容,适合粘贴大段代码。** + +> **与其它 Skill 的区别**:本 Skill 操作的是本地代码片段库(存储在 `~/.config/gitlink-cli/snippets.json`),不涉及 GitLink 平台的 Issue/PR/Repo 等资源。若需操作平台资源,请使用对应域的 Skill(如 `gitlink-issue`、`gitlink-pr`)。 + +## 功能定位 + +gitlink-snippet 是开发者的「个人代码片段收藏夹」,帮助 AI Agent 和用户: +- **沉淀常用代码**:把反复查阅的片段(SQL 模板、正则、初始化脚本)存起来 +- **快速检索**:按标签、语言、关键词或全文检索定位片段 +- **复用与导出**:把片段导出为文件,或直接粘贴到当前任务中 + +## Shortcuts + +| Shortcut | 说明 | 操作类型 | +|----------|------|----------| +| `snippet +create` | 创建代码片段 | ⚠️ Write | +| `snippet +list` | 列出所有片段(支持过滤) | Read | +| `snippet +view` | 查看片段详情 | Read | +| `snippet +search` | 全文搜索片段 | Read | +| `snippet +update` | 更新片段字段 | ⚠️ Write | +| `snippet +delete` | 删除片段 | 🔴 Destructive | +| `snippet +export` | 导出片段到文件 | Read | + +## 参数参考 + +### snippet +create + +| 参数 | 简写 | 必填 | 说明 | +|------|------|:----:|------| +| `--title` | `-t` | ✅ | 片段标题 | +| `--language` | `-l` | 否 | 编程语言(如 `go`、`python`) | +| `--tags` | `-g` | 否 | 标签(逗号分隔,如 `sql,template`) | +| `--content` | `-c` | 否 | 片段内容;传 `-` 表示从 stdin 读取 | + +### snippet +list + +| 参数 | 简写 | 必填 | 说明 | +|------|------|:----:|------| +| `--tag` | `-t` | 否 | 按标签过滤 | +| `--language` | `-l` | 否 | 按语言过滤 | +| `--keyword` | `-k` | 否 | 按标题关键词过滤 | + +### snippet +view / +update / +delete / +export + +| 参数 | 简写 | 必填 | 说明 | +|------|------|:----:|------| +| `--id` | `-i` | ✅ | 片段 ID(8 位十六进制,从 `+list` 获取) | + +`+update` 额外支持 `--title / -t`、`--language / -l`、`--tags / -g`、`--content / -c`(至少传一个字段)。 + +`+export` 额外支持 `--output / -o`(输出文件路径,默认打印到 stdout)。 + +### snippet +search + +| 参数 | 简写 | 必填 | 说明 | +|------|------|:----:|------| +| `--query` | `-q` | ✅ | 搜索关键词(全文匹配) | + +## 使用示例 + +```bash +# 创建片段(直接传内容) +gitlink-cli snippet +create \ + --title "Postgres 慢查询检查" \ + --language sql \ + --tags db,performance \ + --content "SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;" + +# 创建片段(从 stdin 读取大段代码) +cat ./init_script.py | gitlink-cli snippet +create \ + --title "项目初始化脚本" \ + --language python \ + --tags init \ + --content - + +# 列出所有片段 +gitlink-cli snippet +list --format json + +# 按标签 + 语言过滤 +gitlink-cli snippet +list --tag sql --language sql + +# 按标题关键词过滤 +gitlink-cli snippet +list --keyword "Postgres" + +# 查看片段详情 +gitlink-cli snippet +view --id a1b2c3d4 --format json + +# 全文搜索 +gitlink-cli snippet +search --query "慢查询" + +# 更新片段标题和标签 +gitlink-cli snippet +update --id a1b2c3d4 --title "PG 慢查询 Top 10" --tags db,perf + +# 导出片段到文件 +gitlink-cli snippet +export --id a1b2c3d4 --output ./slow_query.sql + +# 删除片段(⚠️ 不可恢复) +gitlink-cli snippet +delete --id a1b2c3d4 +``` + +## 输出示例(`+list --format json`) + +```json +{ + "ok": true, + "data": [ + { + "id": "a1b2c3d4", + "title": "Postgres 慢查询检查", + "language": "sql", + "tags": ["db", "performance"], + "content": "SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;", + "created_at": "2026-06-15T10:30:00+08:00", + "updated_at": "2026-06-15T10:30:00+08:00" + } + ] +} +``` + +## 工作流 + +### 工作流 1:沉淀反复使用的代码 + +**场景**:在排查/开发中遇到一段值得复用的代码,保存下来供日后使用。 + +```bash +# 1. 把代码通过 stdin 存入(适合多行) +cat ./docker-compose-template.yml | gitlink-cli snippet +create \ + --title "通用 docker-compose 模板" \ + --language yaml \ + --tags docker,template \ + --content - + +# 2. 验证已保存 +gitlink-cli snippet +list --keyword "docker" +``` + +### 工作流 2:按需检索并复用 + +**场景**:写新代码时想调用之前存的片段。 + +```bash +# 1. 按关键词搜索 +gitlink-cli snippet +search --query "慢查询" + +# 2. 查看命中的片段内容 +gitlink-cli snippet +view --id a1b2c3d4 + +# 3. 导出为文件后直接使用,或由 AI Agent 直接读取 content 字段复用 +gitlink-cli snippet +export --id a1b2c3d4 --output ./tmp_query.sql +``` + +## 决策规则 + +| 场景 | 推荐命令 | +|------|---------| +| 已知片段 ID,想看内容 | `snippet +view --id <id>` | +| 只记得片段大概是关于什么 | `snippet +search --query "<关键词>"` | +| 想按标签浏览一类片段 | `snippet +list --tag <tag>` | +| 要批量查看某种语言的片段 | `snippet +list --language <lang>` | +| 大段代码要保存 | `snippet +create --content -`(stdin) | +| 要把片段给到脚本/工具用 | `snippet +export --id <id> --output <file>` | + +## 注意事项 + +- **纯本地存储**:片段存于 `~/.config/gitlink-cli/snippets.json`,可通过 `GITLINK_CONFIG_DIR` 环境变量改变存储目录 +- **不需要登录**:本 Skill 不调用 GitLink API,不依赖 `auth login` +- **ID 格式**:8 位十六进制随机串(如 `a1b2c3d4`),通过 `+list` / `+search` 获取 +- **`+delete` 不可恢复**:删除前用 `+view` 确认内容,避免误删 +- **stdin 模式**:`--content -` 表示从标准输入读取,适合保存剪贴板或多行代码 +- **标签是字符串数组**:`--tags` 用逗号分隔(`sql,template`),不支持空格 +- **全文搜索**:`+search` 匹配 title 和 content;若只想按标题找,用 `+list --keyword` + +## References + +- 全局参数与输出格式:[gitlink-shared/SKILL.md](../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-snippet/examples/snippet-workflow.md b/skills/gitlink-snippet/examples/snippet-workflow.md new file mode 100644 index 0000000..8ab7de4 --- /dev/null +++ b/skills/gitlink-snippet/examples/snippet-workflow.md @@ -0,0 +1,131 @@ +# 本地代码片段管理完整工作流示例 + +**场景**:开发者想在本地沉淀常用代码片段(SQL、模板、脚本),并按需检索复用。 + +## 前置条件 + +- `gitlink-cli` 已安装 +- **无需登录认证**(本功能为本地存储,不调用 GitLink 平台 API) + +## 工作流步骤 + +### Step 1:创建代码片段 + +```bash +# 方式 1:直接传内容 +gitlink-cli snippet +create \ + --title "Postgres 慢查询检查" \ + --language sql \ + --tags db,performance \ + --content "SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;" +``` + +**输出示例:** +```json +{ + "ok": true, + "data": { + "id": "a1b2c3d4", + "title": "Postgres 慢查询检查", + "language": "sql", + "tags": ["db", "performance"] + } +} +``` + +```bash +# 方式 2:从 stdin 读取大段代码(推荐用于多行) +cat ./init_script.py | gitlink-cli snippet +create \ + --title "项目初始化脚本" \ + --language python \ + --tags init,setup \ + --content - +``` + +### Step 2:浏览与过滤片段 + +```bash +# 列出所有片段 +gitlink-cli snippet +list --format json + +# 按标签过滤 +gitlink-cli snippet +list --tag db + +# 按语言过滤 +gitlink-cli snippet +list --language python + +# 按标题关键词过滤 +gitlink-cli snippet +list --keyword "Postgres" +``` + +### Step 3:检索与查看 + +```bash +# 全文搜索(匹配 title 和 content) +gitlink-cli snippet +search --query "慢查询" + +# 查看片段详情 +gitlink-cli snippet +view --id a1b2c3d4 --format json +``` + +### Step 4:更新片段 + +```bash +# 更新标题和标签 +gitlink-cli snippet +update --id a1b2c3d4 --title "PG 慢查询 Top 10" --tags db,perf + +# 更新内容(stdin) +echo "SELECT * FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20;" | \ + gitlink-cli snippet +update --id a1b2c3d4 --content - +``` + +### Step 5:导出与复用 + +```bash +# 导出到文件 +gitlink-cli snippet +export --id a1b2c3d4 --output ./slow_query.sql + +# 导出后直接执行 +psql -d mydb -f ./slow_query.sql +``` + +### Step 6:清理不需要的片段(⚠️ 需确认) + +```bash +# 先确认要删除的内容 +gitlink-cli snippet +view --id a1b2c3d4 + +# 确认无误后删除(不可恢复) +gitlink-cli snippet +delete --id a1b2c3d4 +``` + +--- + +## 完整命令速览 + +```bash +# 增 +gitlink-cli snippet +create --title "..." --language <lang> --tags <t1,t2> --content "<code>" +gitlink-cli snippet +create --title "..." --content - # stdin + +# 查 +gitlink-cli snippet +list [--tag <t>] [--language <lang>] [--keyword <kw>] --format json +gitlink-cli snippet +view --id <id> --format json +gitlink-cli snippet +search --query "<keyword>" + +# 改 +gitlink-cli snippet +update --id <id> [--title "..." --tags "..." --content "..."] + +# 删 / 导出 +gitlink-cli snippet +delete --id <id> +gitlink-cli snippet +export --id <id> --output <file> +``` + +--- + +## 注意事项 + +- 片段存储于 `~/.config/gitlink-cli/snippets.json`(可用 `GITLINK_CONFIG_DIR` 改路径) +- 不需要 `auth login`,纯本地功能 +- `+delete` 不可恢复,删除前用 `+view` 确认 +- `--content -` 表示从 stdin 读取,适合保存剪贴板或多行代码 diff --git a/skills/gitlink-stale-issue-manager/examples/stale-issue-manager-workflow.md b/skills/gitlink-stale-issue-manager/examples/stale-issue-manager-workflow.md new file mode 100644 index 0000000..e884a61 --- /dev/null +++ b/skills/gitlink-stale-issue-manager/examples/stale-issue-manager-workflow.md @@ -0,0 +1,32 @@ +# gitlink-stale-issue-manager · 端到端示例 + +## 场景 +识别长期无活动 Issue,按过期等级标记/提醒/批量关闭。 + +## 前置条件 + +- 已安装 gitlink-cli(`npm install -g @gitlink-ai/cli` 或 `go build`) +- 已登录:`gitlink-cli auth login`(平台命令需认证) +- 目标仓库:`--owner <owner> --repo <repo>`(git 仓库内可自动解析) + +## 分步操作 + +```bash +gitlink-cli issue +list --state open --owner <owner> --repo <repo> --page 1 --limit 20 --format json +gitlink-cli issue +comment +gitlink-cli issue +close +gitlink-cli issue +comment --number <issue_id> --owner <owner> --repo <repo> --body "<stale 提醒>" +gitlink-cli issue +close --number <issue_id> --owner <owner> --repo <repo> +``` + +## 输出示例 + +命令返回统一 envelope: +```json +{"ok":true,"data":{ ... }} +``` + +## 命令速览 + +`gitlink-cli issue +list --state open` | `gitlink-cli issue +comment` | `gitlink-cli issue +close` | `gitlink-cli issue +comment --number <issue_id>` + diff --git a/skills/gitlink-todo/SKILL.md b/skills/gitlink-todo/SKILL.md new file mode 100644 index 0000000..5842df3 --- /dev/null +++ b/skills/gitlink-todo/SKILL.md @@ -0,0 +1,151 @@ +--- +name: gitlink-todo +version: 1.0.0 +description: "我的待办:跨 Issue/PR 汇总「分配给我的 / @我的 / 我的 PR 被 review 的」,生成个人待办清单。当用户提到「我的待办」「我有什么要做的」「@我」「待办清单」「todo」「我的任务」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli --help" +--- + +# gitlink-todo(我的待办) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**CRITICAL — 本 Skill 默认只读汇总;自动回复/关闭等写操作必须先确认用户意图。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 功能定位 + +解决「没有『我的』视角」的体验痛点:GitLink 的 Issue/PR 分散在各处,用户难以一眼看到「**哪些事在等我**」。本 Skill 聚合个人维度的待办,按紧急度排序输出清单。 + +| 阶段 | 操作 | AI Agent 角色 | +|------|------|--------------| +| ① 识别身份 | 获取当前登录用户 | `api GET /users/me` | +| ② 多维查询 | 分配我的 / @我的 / 我的PR | 并行采集三类待办 | +| ③ 排序 | 按紧急度(@我 > PR待review > 指派issue) | 标注优先级与停留时长 | +| ④ 输出 | 个人待办清单 | 生成 Markdown | + +## 数据源(均为只读) + +| 待办类型 | 命令 | 说明 | +|---------|------|------| +| 分配给我的 Issue | `gitlink-cli search +issues --assignee <me> --category opened` | 我的负责项 | +| @我的消息 | `gitlink-cli api GET "users/<me>/messages.json"` | 被提及/被通知 | +| 我的 PR 状态 | `gitlink-cli pr +list --format json` | 我提的 PR 是否被 review/合并 | +| 待我 Review 的 PR | `gitlink-cli pr +list --state open --format json` | 分配我 review 的 | + +> `search +issues --assignee`(简写 `-a`)已确认可用(`shortcuts/search/search.go`)。 + +## 使用示例 + +```bash +# 1. 获取当前用户身份 +gitlink-cli api GET "users/me" --format json + +# 2. 找分配给我的开放 Issue +gitlink-cli search +issues --assignee <me> --category opened + +# 3. 找 @我 的消息 +gitlink-cli api GET "users/<me>/messages.json" + +# 4. 查看我的 PR 状态(按作者过滤) +gitlink-cli pr +list --format json +``` + +## 工作流 + +### 工作流 1:生成我的待办(核心) + +**场景**:用户问「我今天有什么要做的 / 给我看看我的待办」。 + +#### Step 1:识别身份 + +```bash +gitlink-cli api GET "users/me" --format json +# 从返回取 login / user_name 作为 <me> +``` + +#### Step 2:多维并行查询 + +```bash +# 分配给我的 Issue +gitlink-cli search +issues --assignee <me> --category opened + +# @我的消息 +gitlink-cli api GET "users/<me>/messages.json" + +# 我的 PR 状态 +gitlink-cli pr +list --format json +``` + +#### Step 3:AI 排序聚合 + +按紧急度分级: +- **🔴 紧急**:@我且停留 >24h 的消息、被阻塞的 PR +- **🟡 本周内**:分配我的 Issue、待我 review 的 PR +- **🔵 可延后**:低优先级 Issue、我的 PR 已 review 待合并 + +#### Step 4:输出待办清单 + +按下方「输出模板」生成。 + +### 工作流 2:批量处理待办(写操作) + +**场景**:用户看完待办后,想批量更新状态。 + +```bash +# 批量关闭已解决的 Issue(⚠️ 先 dry-run) +gitlink-cli issue +batch-close --numbers 12,15 --dry-run + +# 给待办 Issue 加优先级标签 +gitlink-cli issue +batch-update --ids 20,21 --tag-ids 3 --dry-run + +# 回复 @我的 消息 +gitlink-cli issue +comment --number <n> --body "<回复内容>" +``` + +## 决策规则 + +| 条件 | 处理 | +|------|------| +| 用户身份未知 | 先 `api GET /users/me`,失败则提示登录 | +| @我 的消息停留 >24h | 标为 🔴 紧急置顶 | +| 我的 PR 长期未 review | 提醒「PR #<n> 已 <x> 天未 review,可 @维护者」 | +| 待办过多(>20) | 按优先级截断 top 20,其余计数 | +| 批量写操作 | 必须 `--dry-run` 预览后确认 | + +## 输出模板 + +```markdown +# ✅ 我的待办 — <me>(<YYYY-MM-DD>) + +## 🔴 紧急(<n>) +1. 🔔 Issue #<n> @你:<消息>(停留 2 天) +2. 🔀 PR #<n> 你被指派 review,已等待 3 天 + +## 🟡 本周内(<n>) +- 分配给你的 Issue: + - #<n> <标题>(优先级 high) + - #<n> <标题> +- 待 Review 的 PR:#<n>、#<n> + +## 🔵 你的 PR(<n>) +- #<n> <标题> — ✅ 已 review,待合并 +- #<n> <标题> — ⏳ 等待 review(2 天) + +--- +*由 gitlink-todo Skill 生成,共 <n> 项待办* +``` + +## 注意事项 + +- 本 Skill 核心是**只读汇总**,不修改资源 +- `search +issues --assignee` 需传用户标识(login 或 id),先用 `/users/me` 获取 +- 批量处理(关闭/打标签/回复)属写操作,必须先确认或 `--dry-run` +- 待办优先级由 AI 根据停留时长、@提及、阻塞情况综合判定 + +## References + +- 全局参数与安全规则:[gitlink-shared/SKILL.md](../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-todo/examples/todo-workflow.md b/skills/gitlink-todo/examples/todo-workflow.md new file mode 100644 index 0000000..917cfb2 --- /dev/null +++ b/skills/gitlink-todo/examples/todo-workflow.md @@ -0,0 +1,82 @@ +# 我的待办完整工作流示例 + +**场景**:开发者上班第一件事,想知道「今天有哪些事在等我」。 + +## 前置条件 + +- `gitlink-cli` 已登录 + +## 工作流步骤 + +### Step 1:识别身份 + +```bash +gitlink-cli api GET "users/me" --format json +# 取 login = zhangsan +``` + +### Step 2:多维查询 + +```bash +# 分配给我的开放 Issue +gitlink-cli search +issues --assignee zhangsan --category opened + +# @我的消息 +gitlink-cli api GET "users/zhangsan/messages.json" + +# 我的 PR 状态 +gitlink-cli pr +list --format json +``` + +### Step 3:AI 生成待办清单 + +输出示例: + +```markdown +# ✅ 我的待办 — zhangsan(2026-06-23) + +## 🔴 紧急(2) +1. 🔔 Issue #188 @你:「认证模块的重试逻辑需要你确认」(停留 2 天) +2. 🔀 PR #90 你被指派 review,已等待 3 天 + +## 🟡 本周内(4) +- 分配给你的 Issue: + - #180 修复搜索分页(优先级 high) + - #175 补充 API 文档 +- 待 Review 的 PR:#90、#87 + +## 🔵 你的 PR(2) +- #92 feat: 批量导入 — ✅ 已 review,待合并 +- #88 fix: 登录超时 — ⏳ 等待 review(2 天) + +--- +*由 gitlink-todo Skill 生成,共 8 项待办* +``` + +### Step 4(可选):批量处理 + +```bash +# 回复 @我 的消息 +gitlink-cli issue +comment --number 188 --body "已确认,今天处理" + +# 批量给待办加优先级标签(⚠️ 先 dry-run) +gitlink-cli issue +batch-update --ids 180,175 --tag-ids 3 --dry-run +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli api GET "users/me" --format json +gitlink-cli search +issues --assignee <me> --category opened +gitlink-cli api GET "users/<me>/messages.json" +gitlink-cli pr +list --format json +gitlink-cli issue +comment --number <n> --body "..." +gitlink-cli issue +batch-update --ids <ids> --tag-ids <ids> --dry-run +``` + +## 注意事项 + +- 核心只读汇总;批量写操作必须先确认或 `--dry-run` +- `--assignee` 需传 login/id,先用 `/users/me` 获取 diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..62df0bb 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -22,6 +22,12 @@ metadata: |----------|------|----------| | `user +me` | 当前登录用户 | 是 | | `user +info` | 查看用户详情 | 否 | +| `user +headmaps` | 贡献热力图 | 否 | +| `user +stats-activity` | 活跃度统计 | 否 | +| `user +stats-develop` | 开发能力统计 | 否 | +| `user +stats-role` | 角色定位统计 | 否 | +| `user +stats-major` | 专业定位统计 | 否 | +| `user +trends` | 项目动态趋势 | 否 | ## 使用示例 @@ -31,17 +37,20 @@ gitlink-cli user +me # 查看其他用户 gitlink-cli user +info --login zhangsan + +# 查看贡献热力图 +gitlink-cli user +headmaps --login zhangsan + +# 查看统计信息 +gitlink-cli user +stats-activity --login zhangsan +gitlink-cli user +stats-develop --login zhangsan +gitlink-cli user +stats-role --login zhangsan +gitlink-cli user +stats-major --login zhangsan + +# 查看项目动态 +gitlink-cli user +trends --login zhangsan --limit 20 ``` -## Raw API 补充 +## 注意事项 -```bash -# 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps - -# 用户统计 -gitlink-cli api GET /users/:user_id/statistics - -# 用户项目动态 -gitlink-cli api GET /users/:user_id/project_trends -``` +- 查看其他用户信息需要提供 `--login` 参数 diff --git a/skills/gitlink-user/examples/user-workflow.md b/skills/gitlink-user/examples/user-workflow.md new file mode 100644 index 0000000..add0eed --- /dev/null +++ b/skills/gitlink-user/examples/user-workflow.md @@ -0,0 +1,39 @@ +# 用户信息完整工作流示例 + +**场景**:开发者需要查看 GitLink 用户信息。 + +## 工作流步骤 + +### Step 1:查看当前用户信息 + +```bash +# 查看当前登录用户 +gitlink-cli auth status +``` + +### Step 2:查看其他用户信息 + +```bash +# 通过 Raw API 获取用户信息 +gitlink-cli api GET /users/me --format json + +# 获取指定用户信息 +gitlink-cli api GET /users/<username> --format json +``` + +### Step 3:搜索用户 + +```bash +# 通过搜索命令查找用户 +gitlink-cli search +users --keyword "zhangsan" +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli auth status +gitlink-cli api GET /users/me --format json +gitlink-cli search +users --keyword "<keyword>" +``` diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md index 185c74f..b67f144 100644 --- a/skills/gitlink-webhook/SKILL.md +++ b/skills/gitlink-webhook/SKILL.md @@ -1,33 +1,66 @@ --- name: gitlink-webhook -version: 1.0.0 -description: "Webhook 配置与测试:列出、创建、查看、更新、删除并触发 webhook 测试。" +version: 1.1.0 +description: "Webhook 管理:列出、查看、创建、更新、删除、测试 Webhook。当用户需要管理 GitLink 项目 Webhook 时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli webhook --help" --- -# gitlink-webhook(Webhook 管理) +# gitlink-webhook(Webhook 操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** ## Shortcuts | Shortcut | 说明 | |----------|------| -| `webhook +list` | 列出仓库 webhook | -| `webhook +create` | 创建 webhook | -| `webhook +view` | 查看 webhook 详情 | -| `webhook +update` | 更新 webhook | -| `webhook +delete` | 删除 webhook | -| `webhook +test` | 触发 webhook 测试投递 | -| `webhook +tasks` | 查看 webhook 投递任务历史 | +| `webhook +list` | 列出 Webhook | +| `webhook +view` | 查看 Webhook 详情 | +| `webhook +create` | 创建 Webhook | +| `webhook +update` | 更新 Webhook 配置 | +| `webhook +delete` | 删除 Webhook | +| `webhook +history` | 查看 Webhook 推送历史 | +| `webhook +test` | 测试 Webhook 推送 | ## 使用示例 ```bash -gitlink-cli webhook +list --owner Gitlink --repo forgeplus -gitlink-cli webhook +create --owner Gitlink --repo forgeplus \ - --url https://example.com/hook --events push,create -gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 -gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 +# 列出 Webhook +gitlink-cli webhook +list --owner myuser --repo myrepo + +# 查看 Webhook 详情 +gitlink-cli webhook +view --owner myuser --repo myrepo --id 1 + +# 创建 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo \ + --url https://example.com/webhook \ + --events push,issues \ + --secret my-secret-key + +# 更新 Webhook URL 和事件 +gitlink-cli webhook +update --owner myuser --repo myrepo \ + --id 1 \ + --url https://example.com/new-hook \ + --events push,issues,pull_request + +# 查看推送历史 +gitlink-cli webhook +history --owner myuser --repo myrepo --id 1 + +# 测试推送 +gitlink-cli webhook +test --owner myuser --repo myrepo --id 1 + +# 删除 Webhook +gitlink-cli webhook +delete --owner myuser --repo myrepo --id 1 ``` + +## API 注意事项 + +- Webhook 使用 v1 API:`/v1/{owner}/{repo}/webhooks` +- 创建 Webhook 时 `--events` 为逗号分隔的事件列表,支持:push, issues, pull_request, create, delete 等 +- 不指定 `--events` 时默认监听 push 事件 +- `--content-type` 默认为 json,可选 form +- `+test` 命令会实际触发一次 Webhook 推送,请谨慎使用 +- `+history` 返回 Webhook 的推送记录,包含每次推送的状态和响应 diff --git a/skills/gitlink-webhook/examples/webhook-workflow.md b/skills/gitlink-webhook/examples/webhook-workflow.md new file mode 100644 index 0000000..de1d862 --- /dev/null +++ b/skills/gitlink-webhook/examples/webhook-workflow.md @@ -0,0 +1,46 @@ +# Webhook 管理完整工作流示例 + +**场景**:项目维护者需要配置 Webhook 来实现自动化通知。 + +## 工作流步骤 + +### Step 1:查看 Webhook 列表 + +```bash +gitlink-cli webhook +list --owner myorg --repo myproject --format json +``` + +### Step 2:创建 Webhook + +```bash +gitlink-cli webhook +create \ + --owner myorg --repo myproject \ + --url "https://example.com/webhook" \ + --events "push,pull_request,issues" \ + --secret "my-webhook-secret" +``` + +### Step 3:测试和更新 Webhook + +```bash +# 查看 Webhook 详情 +gitlink-cli webhook +view --id 10 --format json + +# 更新 Webhook URL +gitlink-cli webhook +update --id 10 --url "https://new.example.com/webhook" + +# 删除 Webhook(⚠️ 需确认) +gitlink-cli webhook +delete --id 10 +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli webhook +list --owner <owner> --repo <repo> --format json +gitlink-cli webhook +create --url <url> --events <events> --secret <secret> +gitlink-cli webhook +view --id <id> --format json +gitlink-cli webhook +update --id <id> --url <url> +gitlink-cli webhook +delete --id <id> +``` diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md index b64d5be..c022e23 100644 --- a/skills/gitlink-wiki/SKILL.md +++ b/skills/gitlink-wiki/SKILL.md @@ -1,79 +1,52 @@ --- name: gitlink-wiki -version: 2.0.0 -description: "Wiki 页面管理:查看目录、查看、创建、更新和删除 GitLink Wiki 页面。" +version: 1.0.0 +description: "Wiki 操作:查看、创建、更新、删除 Wiki 页面。当用户需要管理 GitLink 仓库 Wiki 时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli wiki --help" --- -# gitlink-wiki +# gitlink-wiki(Wiki 操作) -**重要**: 开始操作前请先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中涵盖认证、权限、全局参数和 GitLink API 行为说明。 -**重要**: 执行写入或破坏性操作(如 `+create`、`+update` 或 `+delete`)前,请先确认用户意图。 -**重要**: 操作 GitLink 资源请使用 `gitlink-cli`,不要使用 `gh` 等 GitHub 专用工具。 +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** -## 快捷命令 +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 -| 快捷命令 | 说明 | 操作类型 | +## Shortcuts + +| Shortcut | 说明 | 需要认证 | |----------|------|----------| -| `wiki +list` | 列出 Wiki 页面(目录结构) | 只读 | -| `wiki +view` | 按页面名称查看 Wiki 页面详情 | 只读 | -| `wiki +create` | 创建新的 Wiki 页面 | 写入 | -| `wiki +update` | 更新 Wiki 页面标题和/或内容 | 写入 | -| `wiki +delete` | 删除 Wiki 页面 | 破坏性 | +| `wiki +list` | 列出 Wiki 页面 | 是 | +| `wiki +view` | 查看 Wiki 页面内容 | 是 | +| `wiki +create` | 创建 Wiki 页面 | 是 | +| `wiki +update` | 更新 Wiki 页面 | 是 | +| `wiki +delete` | 删除 Wiki 页面 | 是 | ## 使用示例 ```bash -# 列出 Wiki 目录 -gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 +# 列出所有 Wiki 页面 +gitlink-cli wiki +list --owner myuser --repo myrepo # 查看 Wiki 页面 -gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home +gitlink-cli wiki +view --name Home # 创建 Wiki 页面 -gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ - -n getting-started -t "快速开始" -c "# 快速开始指南\n\n这是入门文档。" +gitlink-cli wiki +create --name Guide --content "使用指南内容" -# 创建时附带提交信息 -gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ - -n api-guide -t "API 指南" -c "# API 指南" -m "Add API guide" - -# 仅更新页面标题 -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" - -# 仅更新页面内容 -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容" - -# 同时更新标题和内容 -gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 \ - -n home -t "新标题" -c "新内容" +# 更新 Wiki 页面(带提交信息) +gitlink-cli wiki +update --name Guide --content "更新后的内容" --message "更新使用指南" # 删除 Wiki 页面 -gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page +gitlink-cli wiki +delete --name OldPage ``` -## 参数说明 +## 注意事项 -| 命令 | 关键参数 | -|------|----------| -| `+list` | `--project-id` | -| `+view` | `--project-id`、`--page-name` (`-n`) | -| `+create` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`)、`--content` (`-c`)、`--message` (`-m`) | -| `+update` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`),可选 `--content` (`-c`)、`--message` (`-m`) | -| `+delete` | `--project-id`、`--page-name` (`-n`) | - -## API 说明 - -- 所有 Wiki 端点使用 `/api/wiki/{action}` 扁平路径结构(非 REST 嵌套路径)。 -- 目录列表: `GET /api/wiki/wikiPages`(查询参数:owner, repo, projectId) -- 查看详情: `GET /api/wiki/getWiki`(查询参数:owner, repo, projectId, pageName) -- 创建页面: `POST /api/wiki/createWiki`(JSON body,content 需 base64 编码) -- 更新页面: `PUT /api/wiki/updateWiki`(JSON body,content 需 base64 编码) -- 删除页面: `DELETE /api/wiki/deleteWiki`(JSON body:owner, repo, projectId, pageName) -- Wiki 页面通过 `pageName`(slug)标识,而非数字 ID。 -- 所有操作都需要 `--project-id`(GitLink 项目数字 ID)。 -- 创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送。 -- `+update` 要求必须提供 `--title` 和 `--page-name`;`--content` 为可选。 +- Wiki 命令会自动从仓库信息中获取 `projectId`,无需手动指定 +- `--content` 参数的内容会自动进行 base64 编码 +- 在 git 仓库目录下执行时,`--owner` 和 `--repo` 会自动解析 diff --git a/skills/gitlink-wiki/examples/wiki-workflow.md b/skills/gitlink-wiki/examples/wiki-workflow.md new file mode 100644 index 0000000..551c557 --- /dev/null +++ b/skills/gitlink-wiki/examples/wiki-workflow.md @@ -0,0 +1,60 @@ +# Wiki 管理完整工作流示例 + +**场景**:项目维护者需要管理项目 Wiki 文档。 + +## 工作流步骤 + +### Step 1:列出 Wiki 页面 + +```bash +gitlink-cli wiki +list --owner myorg --repo myproject +``` + +**输出示例:** +```json +{ + "ok": true, + "data": [ + { "name": "Home", "title": "首页", "updated_at": "2026-05-20" }, + { "name": "Getting-Started", "title": "快速开始", "updated_at": "2026-05-18" }, + { "name": "API-Reference", "title": "API 参考", "updated_at": "2026-05-15" } + ] +} +``` + +### Step 2:查看和创建 Wiki 页面 + +```bash +# 查看 Wiki 页面内容 +gitlink-cli wiki +view --name Getting-Started + +# 创建新 Wiki 页面 +gitlink-cli wiki +create \ + --name "Deployment-Guide" \ + --content "# 部署指南\n\n## Docker 部署\n\n```bash\ndocker build -t myapp .\ndocker run -p 8080:8080 myapp\n```\n\n## 手动部署\n\n..." +``` + +### Step 3:更新和删除 Wiki 页面 + +```bash +# 更新 Wiki 页面 +gitlink-cli wiki +update \ + --name "Getting-Started" \ + --content "# 快速开始(已更新)\n\n新增了环境变量配置说明..." \ + --message "更新环境变量配置" + +# 删除 Wiki 页面(⚠️ 需确认) +gitlink-cli wiki +delete --name Old-Page +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli wiki +list --owner <owner> --repo <repo> +gitlink-cli wiki +view --name <page-name> +gitlink-cli wiki +create --name <name> --content "..." +gitlink-cli wiki +update --name <name> --content "..." --message "..." +gitlink-cli wiki +delete --name <name> +``` diff --git a/skills/gitlink-workflow/examples/workflow-examples.md b/skills/gitlink-workflow/examples/workflow-examples.md new file mode 100644 index 0000000..0a858e9 --- /dev/null +++ b/skills/gitlink-workflow/examples/workflow-examples.md @@ -0,0 +1,74 @@ +# AI 工作流完整工作流示例 + +**场景**:利用 AI 自动化工作流完成 Issue 分类、PR Review 和报告生成。 + +## 工作流步骤 + +### 工作流 A:Issue 自动分类 + +```bash +# 1. 获取开放的 Issue +gitlink-cli issue +list --state open --format json + +# 2. 分析每个 Issue 并分类 +# AI 根据关键词判断:bug → bug标签,feature → enhancement标签 +gitlink-cli issue +view --number 200 --format json + +# 3. 应用标签 +gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[1]}' +``` + +### 工作流 B:生成 PR 摘要报告 + +```bash +# 生成 PR 审查摘要(只读模式) +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown +``` + +### 工作流 C:生成仓库报告 + +```bash +# 生成综合仓库报告 +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +``` + +**输出示例(Markdown):** +```markdown +# 仓库报告 — Gitlink/gitlink-cli + +## 基本信息 +- 语言:Go +- 开放 Issue:12 +- 开放 PR:3 +- 最近贡献者:@dev1, @dev2, @dev3 + +## Issue 分布 +- Bug:5 +- Enhancement:4 +- Question:3 + +## 建议 +- 3 个 Issue 超过 30 天未响应,建议处理 +``` + +### 工作流 D:Sprint 报告 + +```bash +# 获取 Issue 和 PR 统计 +gitlink-cli issue +list --state open --format json +gitlink-cli issue +list --state closed --format json +gitlink-cli pr +list --state merged --format json + +# 获取项目动态 +gitlink-cli api GET /:owner/:repo/activity --format json +``` + +--- + +## 完整命令速览 + +```bash +gitlink-cli workflow +pr-summary --owner <owner> --repo <repo> --number <n> --format markdown +gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format markdown +gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json +``` diff --git a/workflows/community-ops-sweep/README.md b/workflows/community-ops-sweep/README.md new file mode 100644 index 0000000..1e16400 --- /dev/null +++ b/workflows/community-ops-sweep/README.md @@ -0,0 +1,83 @@ +# community-ops-sweep —— 社区运营增量对账 sweep + +## 概述 + +一个 [Claude Code Workflow](../../../README.md) 实现的**增量对账式**社区运营自动化: +运行时给定一个 `since` 时间点(或默认读上次 checkpoint),处理该时间点后的所有**新增 Issue** 与**合并 PR**,按需更新社区周报与 Release Notes。 + +**一次调用、时间窗界定、5 项事件一网打尽**:R1 新 Issue triage → R2 建议 owner → R3 周报 → R4 Release Notes → R5 PR→Issue 关联闭环。 + +> 完整设计见 [`docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md`](/docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md) + +## 架构 + +三层,JSON 文件交接(判断与计算分离): + +``` +CC Workflow (JS) ……… 编排:按 phase 调度 + ├── LLM agent 层 …… 只做判断:分类 / PR 关联 / owner 建议 / 散文增强 + │ ↕ JSON 文件 + └── Python 引擎 …… 只做计算:采集 / 归一化 / id 解析 / 合并 / 守卫 / 渲染 / 写回 +``` + +确定性引擎(`scripts/community_ops_sweep.py`)复用 `community-ops-automation` 的采集/归一化/渲染管线,真实字段名实测自 `baoerjun/gitlink-cli`。 + +## 前置条件 + +- **gitlink-cli** ≥ 0.2.0(`npm install -g @gitlink-ai/cli`) +- **Python** 3.9+(标准库,无第三方依赖) +- **gitlink-cli auth login**(认证) +- **Claude Code**(运行 workflow) + +## 运行方式 + +### 在 Claude Code 中 + +```bash +# 作为 slash 命令(若已放入 .claude/workflows/) +/community-ops-sweep owner=baoerjun repo=gitlink-cli + +# 或通过 Workflow 工具 +# 带入参数:since 可选(ISO 时间或留空读 .last-sweep)、apply 默认 false +``` + +### 单独跑确定性引擎 + +```bash +# collect:采集 since 后的新 issue + 合并 PR +python scripts/community_ops_sweep.py collect --owner baoerjun --repo gitlink-cli --since 2026-06-01T00:00:00Z + +# plan:读 candidates + LLM decisions → 写计划 +python scripts/community_ops_sweep.py plan --candidates candidates.json --triage triage.json --owners owners.json --links links.json + +# apply:dry-run 预览,--apply 才真写 +python scripts/community_ops_sweep.py apply --plan plan.json --owner baoerjun --repo gitlink-cli --apply + +# checkpoint:推进 .last-sweep + 追加 triage-log +python scripts/community_ops_sweep.py checkpoint --owner baoerjun --repo gitlink-cli +``` + +### 跑单测 + +```bash +python3 tests/test_sweep.py # 18 个确定性逻辑回归 +``` + +## 文件布局 + +``` +community-ops-sweep/ + community-ops-sweep.wf.js # CC workflow (JS) + scripts/community_ops_sweep.py # 确定性引擎 + routing-rules.example.yaml # R2 路由规则(opt-in auto_assign) + tests/test_sweep.py # 引擎单测 + README.md +``` + +## 安全 + +- 默认 **dry-run**,`--apply` 才写 +- 标签**整体替换**发期望全集(增删统一,不丢标签) +- 关 issue 前自动守卫(已关跳过) +- **绝不自动合并 PR** +- 可逆写(关可 reopen、release 可 edit)→ 无二次确认 diff --git a/workflows/community-ops-sweep/community-ops-sweep.wf.js b/workflows/community-ops-sweep/community-ops-sweep.wf.js new file mode 100644 index 0000000..aa92893 --- /dev/null +++ b/workflows/community-ops-sweep/community-ops-sweep.wf.js @@ -0,0 +1,164 @@ +export const meta = { + name: 'community-ops-sweep', + description: 'Incremental community ops sweep — triage new issues (R1), suggest owners (R2), link merged PRs→issues (R5), generate weekly report + release notes (R3/R4), upload report to Wiki. Default dry-run; --apply to write.', + phases: [ + { title: 'Collect', detail: 'Python engine gathers new issues + merged/open PRs + contributor stats since checkpoint' }, + { title: 'Triage', detail: 'LLM agents classify, suggest owners, link PRs — in parallel' }, + { title: 'Summarize', detail: 'LLM generates executive summary from collected data' }, + { title: 'Plan', detail: 'Python engine builds deterministic write plan + generates seven-section report' }, + { title: 'Apply', detail: 'Execute (or dry-run) write plan + publish to Wiki + advance checkpoint' }, + ], +} + +const cfg = args || {} +const OWNER = cfg.owner +const REPO = cfg.repo +const APPLY = cfg.apply === true +const REPORT_ISSUE = cfg.reportIssue || '' +const RELEASE_ID = cfg.releaseId || '' +const SINCE = cfg.since || '' +const PUBLISH_WIKI = cfg.publishWiki === true +const WIKI_DIR = cfg.wikiDir || '周报专区' +const REPORT_FLAG = REPORT_ISSUE ? `--report-issue ${REPORT_ISSUE}` : '' +const RELEASE_FLAG = RELEASE_ID ? `--release-id ${RELEASE_ID}` : '' +const WIKI_FLAG = PUBLISH_WIKI ? `--publish-wiki --wiki-dir ${WIKI_DIR}` : '' + +const ENGINE = 'examples/workflows/community-ops-sweep/scripts/community_ops_sweep.py' +const CANDIDATES = '/tmp/sweep-candidates.json' +const TRIAGE = '/tmp/sweep-triage.json' +const OWNERS = '/tmp/sweep-owners.json' +const LINKS = '/tmp/sweep-links.json' +const PLAN = '/tmp/sweep-plan.json' +const SUMMARY = '/tmp/sweep-summary.json' +const SUMMARY_ARG = `--summary ${SUMMARY}` + +const SINCE_ARG = SINCE ? `--since ${SINCE}` : '' + +if (!OWNER || !REPO) { + throw new Error('Missing required args: owner, repo') +} + +phase('Collect') +log(`Collecting since=${SINCE || 'checkpoint'} for ${OWNER}/${REPO}`) +const collectOut = await agent( + `Run the sweep engine collect:\n` + + ` python3 ${ENGINE} collect --owner ${OWNER} --repo ${REPO} ${SINCE_ARG} --out ${CANDIDATES}\n` + + `Then Read ${CANDIDATES} and return a compact summary: how many new_issues, merged_prs, open_issues, and the tag_map keys. Also list each issue's number, title and current labels.`, + { label: 'collect', phase: 'Collect' } +) +log(`Collect result: ${collectOut}`) + +// ----------------------------------------------------------------------- +// Phase Triage — three LLM agents, independent → parallel +// ----------------------------------------------------------------------- +phase('Triage') +log('R1 classify + R2 suggest owners + R5 link PRs — in parallel') +const triagePrompts = [ + // R1: classify new issues + () => agent( + `## Task: Classify new issues for triage (R1)\n\n` + + `1. Read the file ${CANDIDATES}. It contains:\n` + + ` - "issues": new issues with fields number, title, description, tags, labels\n` + + ` - "tag_map": available label names → IDs. ONLY use labels from tag_map keys.\n\n` + + `2. For EACH issue, determine:\n` + + ` - tag_names: labels to ADD (pick from tag_map keys, e.g. "bug","feature","enhancement","question","docs","P1","P2","P3")\n` + + ` - remove_tag_names: labels to REMOVE if any current ones are wrong\n` + + ` - recommended_action: one of "schedule_fix","duplicate","answered","spam","invalid". Use "schedule_fix" for normal issues.\n` + + ` (duplicate/answered/spam/invalid → will be auto-closed)\n\n` + + `3. Write your decision as a single-line JSON array to ${TRIAGE} using Bash:\n` + + ` cat > ${TRIAGE} << 'EOD'\n [{"issue":"<num>","tag_names":["<name>"],"remove_tag_names":["<name>"],"recommended_action":"<action>"}]\n EOD\n` + + ` Make sure each issue has an entry if there are decisions. Skip issues that need no changes.\n\n` + + `4. Confirm: cat ${TRIAGE}`, + { label: 'triage:R1-classify', phase: 'Triage' } + ), + // R2: suggest owners + () => agent( + `## Task: Suggest owners for new issues (R2)\n\n` + + `1. Read the file ${CANDIDATES}.\n\n` + + `2. For EACH issue, suggest 1-3 likely owners based on:\n` + + ` - The issue content (title, description)\n` + + ` - Author logins of recent merged PRs (in "merged_prs")\n` + + ` - General domain familiarity\n\n` + + `3. Output: write a JSON array to ${OWNERS} using Bash:\n` + + ` cat > ${OWNERS} << 'EOD'\n [{"issue":"<num>","suggested_owners":["<login>"],"evidence":"<why>"}]\n EOD\n` + + ` If you cannot suggest anyone for an issue, omit it.\n\n` + + `4. Confirm: cat ${OWNERS}`, + { label: 'triage:R2-owners', phase: 'Triage' } + ), + // R5: link merged PRs → issues + () => agent( + `## Task: Link merged PRs to issues (R5)\n\n` + + `1. Read the file ${CANDIDATES}. Focus on the "merged_prs" list.\n\n` + + `2. For EACH merged PR, parse its "description" (body) for patterns like:\n` + + ` - "fixes #N", "closes #N", "resolves #N"\n` + + ` - Chinese equivalents: "修复 #N", "关闭 #N", "解决 #N"\n` + + ` - Semantic references that clearly identify a specific issue number\n\n` + + `3. Output: write a JSON array to ${LINKS} using Bash:\n` + + ` cat > ${LINKS} << 'EOD'\n [{"pr":"<num>","linked_issue_numbers":["<num>"]}]\n EOD\n` + + ` If no PR references any issue, write an empty array [].\n\n` + + `4. Confirm: cat ${LINKS}`, + { label: 'triage:R5-link', phase: 'Triage' } + ), +] + +await Promise.all(triagePrompts.map(fn => fn())) +log('Triage complete — decision files written') + +// ----------------------------------------------------------------------- +// Phase Summarize — LLM generates executive summary +// ----------------------------------------------------------------------- +phase('Summarize') +log('Generating executive summary from collected data') +const summaryOut = await agent( + `## Task: Generate weekly report executive summary\n\n` + + `1. Read the file ${CANDIDATES}. It contains:\n` + + ` - "issues": new issues this period (number, title, labels, author_login, state)\n` + + ` - "merged_prs": merged PRs (number, title, author_login)\n` + + ` - "open_prs": pending PRs\n` + + ` - "current_counts": issue/PR counts\n` + + ` - "contributors": top contributors with issue/PR counts\n` + + ` - "trends": trend arrows (↑↓→) compared to last period\n\n` + + `2. Write a 3-5 sentence executive summary in Chinese to ${SUMMARY} using Bash:\n` + + ` cat > ${SUMMARY} << 'EOD'\n {"summary": "本周社区新增 N 个 Issue,合并 M 个 PR。……"}\n EOD\n\n` + + `3. The summary should cover:\n` + + ` - Overall activity level (busy/calm/normal)\n` + + ` - Key highlights (major features merged, important bugs fixed)\n` + + ` - Notable trends (↑ or ↓ compared to last week)\n` + + ` - Any concerns or action items\n\n` + + `4. Confirm: cat ${SUMMARY}`, + { label: 'summarize', phase: 'Summarize' } +) +log(`Summary: ${summaryOut}`) + +// ----------------------------------------------------------------------- +// Phase Plan — deterministic engine assembles write plan + generates report +// ----------------------------------------------------------------------- +phase('Plan') +const planOut = await agent( + `Run the sweep engine plan:\n` + + ` python3 ${ENGINE} plan --candidates ${CANDIDATES} --triage ${TRIAGE} --owners ${OWNERS} --links ${LINKS} ${SUMMARY_ARG} --out ${PLAN}\n` + + `Then Read ${PLAN} and summarize: how many writes, by op (update_tags/update_status/comment/update_assigner), which issues affected. Show the writes table.`, + { label: 'plan', phase: 'Plan' } +) +log(`Plan: ${planOut}`) + +// ----------------------------------------------------------------------- +// Phase Apply — execute (or dry-run) + checkpoint +// ----------------------------------------------------------------------- +phase('Apply') +const mode = APPLY ? '--apply' : '' +const applyOut = await agent( + APPLY + ? `Execute the write plan (--apply):\n python3 ${ENGINE} apply --plan ${PLAN} --owner ${OWNER} --repo ${REPO} --apply ${REPORT_FLAG} ${RELEASE_FLAG} ${WIKI_FLAG}\nThen: python3 ${ENGINE} checkpoint --owner ${OWNER} --repo ${REPO} --plan ${PLAN}\nReturn a summary of writes executed and where the report was published.` + : `Dry-run the write plan (no writes executed):\n python3 ${ENGINE} apply --plan ${PLAN} --owner ${OWNER} --repo ${REPO} ${REPORT_FLAG} ${RELEASE_FLAG} ${WIKI_FLAG}\nSummarize what WOULD be written.`, + { label: 'apply', phase: 'Apply' } +) +log(`Apply: ${applyOut}`) + +return { + mode: APPLY ? 'applied' : 'dry-run', + owner: OWNER, repo: REPO, + collect: collectOut, + plan: planOut, + apply: applyOut, +} diff --git a/workflows/community-ops-sweep/routing-rules.example.yaml b/workflows/community-ops-sweep/routing-rules.example.yaml new file mode 100644 index 0000000..50c8ea8 --- /dev/null +++ b/workflows/community-ops-sweep/routing-rules.example.yaml @@ -0,0 +1,37 @@ +# 路由规则(R2 owner 建议 + 可选 auto_assign) + +# rules:首次命中生效,顺序即优先级 +# 默认用途:R2 建议 owner 的依据(LLM 参考 + 确定性兜底) +# auto_assign: true → 该规则命中的 issue 自动指派(内部团队仓,opt-in) + +rules: + - match: + labels: [bug] + any_keyword: [auth, 认证, 登录, token, oauth, session] + assigner: auth-owner + auto_assign: false + + - match: + labels: [bug] + any_keyword: [ci, build, 构建, pipeline, deploy] + assigner: ci-owner + auto_assign: false + + - match: + labels: [docs] + assigner: doc-maintainer + auto_assign: false + + - match: + labels: [enhancement, feature] + assigner: product-owner + auto_assign: false + + - match: + labels: [security] + assigner: security-team + auto_assign: false + +# 所有未命中规则 → 兜底 +default_assigner: maintainer +auto_assign: false diff --git a/workflows/community-ops-sweep/scripts/community_ops_sweep.py b/workflows/community-ops-sweep/scripts/community_ops_sweep.py new file mode 100644 index 0000000..357dcd2 --- /dev/null +++ b/workflows/community-ops-sweep/scripts/community_ops_sweep.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +"""community_ops_sweep — 社区运营增量对账 sweep 的确定性引擎。 + +三层架构里的「Python 引擎层」:只做事实性计算(采集 / 归一化 / id 解析 / 合并 / +守卫 / 渲染 / 写回);判断(分类 / PR 关联 / owner 建议)由 CC workflow 的 LLM +agent 产出 JSON 喂入。设计见 +docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md。 + +子命令: + collect 采集 since 之后的新 issue + 合并 PR + 标签/分配人映射 → candidates.json + plan 读 candidates + LLM decision JSON → plan.json(写计划) + apply 执行 plan.json(默认 dry-run,--apply 才真写) + checkpoint 推进 .last-sweep + 追加 docs/issue-triage-log.md + +字段名实测自 baoerjun/gitlink-cli:issue 在 data.issues[],PR 在 data.pulls[]。 +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable + +# --------------------------------------------------------------------------- # +# 复用 community-ops-automation 的 CLI 封装与通用 helpers(best-effort:缺失 +# 也不影响纯逻辑导入与单测) +# --------------------------------------------------------------------------- # +_GW_PATH = ( + Path(__file__).resolve().parents[2] + / "community-ops-automation" + / "scripts" + / "gitlink_workflow.py" +) +try: + _spec = importlib.util.spec_from_file_location("_gw", _GW_PATH) + _gw = importlib.util.module_from_spec(_spec) # type: ignore[union-attr] + sys.modules["_gw"] = _gw + _spec.loader.exec_module(_gw) # type: ignore[union-attr] + run_gitlink_cli = _gw.run_gitlink_cli + first_value = _gw.first_value + extract_first_list = _gw.extract_first_list + parse_datetime = _gw.parse_datetime + WorkflowError = _gw.WorkflowError +except Exception: # pragma: no cover - 纯逻辑单测不应依赖 _gw + _gw = None + run_gitlink_cli = None # type: ignore[assignment] + first_value = lambda item, keys, default=None: next( # noqa: E731 + (item[k] for k in keys if isinstance(item, dict) and k in item and item[k] not in (None, "", [])), default + ) + extract_first_list = lambda payload, keys: payload if isinstance(payload, list) else [] # noqa: E731 + parse_datetime = lambda v: None # type: ignore[assignment] # noqa: E731 + + class WorkflowError(RuntimeError): + pass + + +DEFAULT_POLICY = { + "status_map": { + "duplicate": "closed", + "answered": "closed", + "spam": "closed", + "invalid": "closed", + } +} + + +# --------------------------------------------------------------------------- # +# 纯逻辑(确定性、可单测)—— TDD 覆盖 +# --------------------------------------------------------------------------- # +def _id_sort_key(x: Any) -> tuple: + try: + return (0, int(x)) + except (TypeError, ValueError): + return (1, str(x)) + + +def merge_tag_ids(current_ids, add_names, remove_names, tag_map): + """整体替换语义:返回 (current ∪ add) \\ remove;白名单过滤、去重、按数值排序。""" + result = {str(x) for x in (current_ids or []) if x is not None and str(x) != ""} + for name in add_names or []: + tid = (tag_map or {}).get(name) + if tid is not None: + result.add(str(tid)) + for name in remove_names or []: + tid = (tag_map or {}).get(name) + if tid is not None: + result.discard(str(tid)) + return sorted(result, key=_id_sort_key) + + +def map_status(recommended_action, policy): + """recommended_action → 目标状态(如 duplicate→closed);未映射或 None → None(不改)。""" + if not recommended_action: + return None + sm = (policy or {}).get("status_map", {}) or {} + return sm.get(recommended_action) + + +def _issue_current_tag_ids(issue: dict) -> list[str]: + out = [] + for t in issue.get("tags") or []: + if isinstance(t, dict) and t.get("id") is not None: + out.append(str(t["id"])) + return out + + +def coerce_dates(item, keys=("updated_at", "created_at")): + """把 item 中字符串日期字段解析回 datetime(磁盘 round-trip 后的修复)。 + + datetime/None 原样保留;只解析 str。缺失的键补齐为 None。非 dict 原样返回。""" + if not isinstance(item, dict): + return item + out = dict(item) + for k in keys: + v = out.get(k) + if isinstance(v, str): + out[k] = parse_datetime(v) + elif k not in out: + out[k] = None + return out + + +def _render_owner_comment(evidence, suggested) -> str: + names = "、".join(f"@{s}" for s in suggested) + ev = f"(依据:{evidence})" if evidence else "" + return ( + f"🤖 建议负责人:{names}{ev}。\n" + "如非本人负责,请自行重新指派或在下方留言说明。" + ) + + +def build_plan(candidates, triage, owners, links, routing, policy, tag_map): + """把 LLM 判断 + 确定性规则汇编成写计划。纯函数:同输入 → 同输出。""" + policy = policy or {} + routing = routing or {} + tag_map = tag_map or {} + writes: list[dict] = [] + + issues = candidates.get("issues", []) or [] + by_num = {str(i.get("number")): i for i in issues if i.get("number") is not None} + open_issues = {str(x) for x in (candidates.get("open_issues") or [])} + + # R1 triage:标签增删 + 状态映射 + for t in triage or []: + num = str(t.get("issue")) + issue = by_num.get(num) + if issue is None: + continue # 校验:候选里没有 → 跳过,不盲写 + current = _issue_current_tag_ids(issue) + new_ids = merge_tag_ids( + current, t.get("tag_names", []), t.get("remove_tag_names", []), tag_map + ) + if sorted(new_ids, key=_id_sort_key) != sorted(current, key=_id_sort_key): + writes.append({ + "op": "update_tags", "issue": num, "tag_ids": new_ids, + "tag_names": t.get("tag_names", []) or [], + "remove_tag_names": t.get("remove_tag_names", []) or [], + }) + state = map_status(t.get("recommended_action"), policy) + if state: + writes.append({ + "op": "update_status", "issue": num, "state": state, + "reason": f"triage: {t.get('recommended_action')}", + }) + + # R2 owner 建议(默认评论;routing.auto_assign=true 才真指派) + auto_assign = bool(routing.get("auto_assign")) + assigner_map = candidates.get("assigner_map") or {} + for o in owners or []: + num = str(o.get("issue")) + if num not in by_num: + continue + suggested = o.get("suggested_owners") or [] + if not suggested: + continue + writes.append({ + "op": "comment", "issue": num, "kind": "owner_suggest", + "body": _render_owner_comment(o.get("evidence"), suggested), + }) + if auto_assign: + aids = [str(assigner_map[s]) for s in suggested if s in assigner_map] + if aids: + writes.append({"op": "update_assigner", "issue": num, "assigner_ids": aids}) + + # R5 PR→Issue 关联闭环:只关「存在且 open」的(幂等 + 防幻觉编号) + for l in links or []: + pr = l.get("pr") + for inum in l.get("linked_issue_numbers", []) or []: + inum = str(inum) + if inum not in open_issues: + continue # 校验:不存在或非 open → 跳过 + writes.append({ + "op": "update_status", "issue": inum, "state": "closed", + "reason": f"fixed by PR#{pr}", + }) + writes.append({ + "op": "comment", "issue": inum, "kind": "link_close", + "body": f"🤖 PR#{pr} 已合并且语义关联本 issue,自动关闭(可 reopen)。", + }) + + return {"writes": writes} + + +# --------------------------------------------------------------------------- # +# 归一化(实测字段名) +# --------------------------------------------------------------------------- # +def _issue_state(item: dict) -> str: + sid = first_value(item, ("status_id", "state_id"), None) + if sid in (5, "5", "closed", "close"): + return "closed" + return "open" + + +def normalize_issue(item: dict) -> dict: + author = item.get("author") or {} + tags = item.get("tags") or [] + return { + "id": str(first_value(item, ("number", "project_issues_index", "iid"), "")), + "number": str(first_value(item, ("number", "project_issues_index", "iid"), "")), + "title": str(first_value(item, ("subject", "title", "name"), "(untitled)")), + "description": str(first_value(item, ("description", "body", "content"), "")), + "state": _issue_state(item), + "labels": [str(t.get("name")) for t in tags if isinstance(t, dict) and t.get("name")], + "tags": tags, + "author_login": author.get("login") or author.get("name") or "", + "author_id": author.get("id"), + "created_at": parse_datetime(first_value(item, ("created_at", "createdAt"), None)), + "updated_at": parse_datetime(first_value(item, ("updated_at", "updatedAt"), None)), + } + + +def normalize_pr(pull: dict) -> dict: + inner = pull.get("issue") or {} + author = inner.get("author") or pull.get("author") or {} + state = str(first_value(pull, ("status", "state"), "open")).lower() + merged = state == "merged" + return { + "id": str(first_value(pull, ("index", "number", "pull_request_number"), "")), + "number": str(first_value(pull, ("index", "number", "pull_request_number"), "")), + "title": str(first_value(pull, ("title", "name"), "(untitled)")), + "description": str(first_value(pull, ("body", "description", "content"), "")), + "state": state, + "merged": merged, + "base": pull.get("base"), + "head": pull.get("head"), + "author_login": author.get("login") or author.get("name") or "", + "author_id": author.get("id"), + "merged_at": parse_datetime(first_value(pull, ("merged_at", "merge_time"), None)), + "created_at": parse_datetime(first_value(pull, ("created_at", "pr_created_unix"), None)), + "updated_at": parse_datetime(first_value(pull, ("updated_at", "created_at", "pr_created_unix"), None)), + "labels": [str(t.get("name")) for t in (inner.get("issue_tags") or []) if isinstance(t, dict) and t.get("name")], + } + + +def _load_tag_map(owner, repo) -> dict[str, str]: + if run_gitlink_cli is None: + return {} + payload = run_gitlink_cli(["issue", "+tags", "--only-name"], owner, repo) + items = extract_first_list(payload, ("issue_tags", "tags", "items", "list")) + return {str(t.get("name")): str(t.get("id")) for t in items if isinstance(t, dict) and t.get("name") and t.get("id") is not None} + + +def _load_assigner_map(owner, repo) -> dict[str, str]: + if run_gitlink_cli is None: + return {} + payload = run_gitlink_cli(["issue", "+assigners"], owner, repo) + items = extract_first_list(payload, ("assigners", "issue_assigners", "users", "items", "list")) + out = {} + for u in items: + if not isinstance(u, dict): + continue + login = u.get("login") or u.get("name") or u.get("username") + if login and u.get("id") is not None: + out[str(login)] = str(u["id"]) + return out + + +# --------------------------------------------------------------------------- # +# I/O 子命令 +# --------------------------------------------------------------------------- # +def _read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + + +def _parse_since(value: str | None, checkpoint: Path | None) -> datetime: + if value: + dt = parse_datetime(value) + if dt is None: + raise WorkflowError(f"无法解析 --since: {value}") + return dt + if checkpoint and checkpoint.exists(): + dt = parse_datetime(checkpoint.read_text(encoding="utf-8").strip()) + if dt is not None: + return dt + return datetime.now(timezone.utc) - timedelta(days=7) + + +def _collect_all_prs(owner: str, repo: str): + """采集所有 PR(open + merged + closed),返回 (open_prs, merged_prs, all_pr_authors)。""" + all_prs: list[dict] = [] + for state in ("open", "merged", "closed"): + raw = run_gitlink_cli(["pr", "+list", "--state", state, "--limit", "200"], owner, repo) + items = [ + normalize_pr(p) + for p in extract_first_list(raw, ("pulls", "pull_requests", "issues", "items", "list")) + if isinstance(p, dict) + ] + all_prs.extend(items) + # 去重 (同一条 PR 可能出现在多个 state 查询中) + seen: set[str] = set() + deduped: list[dict] = [] + for p in all_prs: + if p["number"] not in seen: + seen.add(p["number"]) + deduped.append(p) + open_prs = [p for p in deduped if p["state"] == "open"] + merged_prs = [p for p in deduped if p["merged"] or p["state"] == "merged"] + authors = sorted({p["author_login"] for p in deduped if p["author_login"]}) + return open_prs, merged_prs, authors + + +def _contributor_ranking(issues: list[dict], merged_prs: list[dict]) -> list[dict]: + """统计贡献者活跃度:合并 PR 数 + 提交 Issue 数。""" + scores: dict[str, dict] = {} + for i in issues: + login = i.get("author_login", "") + if not login: + continue + if login not in scores: + scores[login] = {"login": login, "issues": 0, "prs": 0} + scores[login]["issues"] += 1 + for p in merged_prs: + login = p.get("author_login", "") + if not login: + continue + if login not in scores: + scores[login] = {"login": login, "issues": 0, "prs": 0} + scores[login]["prs"] += 1 + ranked = sorted(scores.values(), key=lambda x: x["prs"] + x["issues"], reverse=True) + return ranked[:10] + + +def _load_previous_summary(checkpoint_dir: Path) -> dict | None: + """读取上一周期的汇总数据用于趋势对比。""" + path = checkpoint_dir / ".last-sweep-summary.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +def _save_current_summary(checkpoint_dir: Path, summary: dict) -> None: + """保存本周期的汇总数据供下一次趋势对比。""" + checkpoint_dir.mkdir(parents=True, exist_ok=True) + (checkpoint_dir / ".last-sweep-summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + + +def cmd_collect(args) -> int: + owner, repo = args.owner, args.repo + checkpoint = Path(args.checkpoint) + since = _parse_since(args.since, checkpoint) + + raw_issues = run_gitlink_cli(["issue", "+list", "--limit", "200"], owner, repo) + all_issues = [ + normalize_issue(i) + for i in extract_first_list(raw_issues, ("issues", "issue_list", "items", "list")) + if isinstance(i, dict) + ] + new_issues = [i for i in all_issues if i["created_at"] and i["created_at"] >= since] + open_issue_numbers = sorted({i["number"] for i in all_issues if i["state"] == "open"}) + closed_issues = [i for i in all_issues if i["state"] == "closed"] + + open_prs, merged_prs, pr_authors = _collect_all_prs(owner, repo) + + # 趋势对比:加载上次汇总 + previous = _load_previous_summary(checkpoint.parent) + current_counts = { + "issues_total": len(all_issues), "issues_open": len(open_issue_numbers), + "issues_closed": len(closed_issues), "prs_open": len(open_prs), + "prs_merged": len(merged_prs), "contributors": len( + {i.get("author_login", "") for i in all_issues if i.get("author_login")} | set(pr_authors) + ), + } + trends = _compute_trends(current_counts, previous) + + # 贡献者排行 + contributors = _contributor_ranking(all_issues, merged_prs) + + # 保存当前汇总供下一次对比 + _save_current_summary(checkpoint.parent, current_counts) + + candidates = { + "owner": owner, "repo": repo, "since": since.isoformat(), + "issues": new_issues, "open_issues": open_issue_numbers, + "closed_issues": [c["number"] for c in closed_issues], + "merged_prs": merged_prs, "open_prs": open_prs, + "trends": trends, "contributors": contributors, + "current_counts": current_counts, "previous_counts": previous, + "tag_map": _load_tag_map(owner, repo), + "assigner_map": _load_assigner_map(owner, repo), + } + _write_json(Path(args.out), candidates) + print(json.dumps({ + "since": since.isoformat(), "new_issues": len(new_issues), + "merged_prs": len(merged_prs), "open_issues": len(open_issue_numbers), + "open_prs": len(open_prs), "contributors": len(contributors), + }, ensure_ascii=False)) + return 0 + + +def _compute_trends(current: dict, previous: dict | None) -> dict[str, str]: + """对比上周期数据,返回趋势箭头。""" + if not previous: + return {} + trends = {} + for key in ("issues_total", "issues_open", "issues_closed", "prs_open", "prs_merged", "contributors"): + prev = previous.get(key, 0) + curr = current.get(key, 0) + if isinstance(prev, (int, float)) and isinstance(curr, (int, float)): + if curr > prev: + trends[key] = f"↑{curr - prev}" + elif curr < prev: + trends[key] = f"↓{prev - curr}" + else: + trends[key] = "→" + return trends + + +def cmd_plan(args) -> int: + candidates = _read_json(Path(args.candidates)) + tag_map = candidates.get("tag_map") or {} + policy = _read_json(Path(args.policy)) if args.policy else DEFAULT_POLICY + routing = _read_json(Path(args.routing)) if args.routing else {} + triage = _read_json(Path(args.triage)) if args.triage and Path(args.triage).exists() else [] + owners = _read_json(Path(args.owners)) if args.owners and Path(args.owners).exists() else [] + links = _read_json(Path(args.links)) if args.links and Path(args.links).exists() else [] + + # 读取 LLM 生成的执行摘要 + summary_path = getattr(args, "summary", None) + if summary_path and Path(summary_path).exists(): + summary_data = _read_json(Path(summary_path)) + candidates["executive_summary"] = summary_data.get("summary", "") if isinstance(summary_data, dict) else "" + + plan = build_plan(candidates, triage, owners, links, routing, policy, tag_map) + # R3: 周报 Markdown 生成(七段式);R4: Release Notes + plan["report_md"] = _generate_report(candidates, plan) + plan["release_notes_md"] = _render_release_notes(candidates) + _write_json(Path(args.out), plan) + print(json.dumps({"writes": len(plan["writes"])}, ensure_ascii=False)) + return 0 + + +def _build_command(w: dict) -> list[str] | None: + op = w.get("op") + num = str(w.get("issue")) + if op == "update_tags": + return ["issue", "+update", "--number", num, "--tag-ids", ",".join(w.get("tag_ids") or [])] + if op == "update_status": + return ["issue", "+update", "--number", num, "--state", str(w.get("state"))] + if op == "comment": + return ["issue", "+comment", "--number", num, "--body", str(w.get("body"))] + if op == "update_assigner": + return ["issue", "+update", "--number", num, "--assigner-ids", ",".join(w.get("assigner_ids") or [])] + return None + + +def _exec_or_dry(args, cmd, label) -> bool: + """dry-run 打印;--apply 才真执行。返回是否执行成功。""" + if not args.apply: + preview = " ".join(cmd[:4]) + (" …" if len(cmd) > 4 else "") + print(f"[dry-run] {label}: {preview}") + return False + try: + run_gitlink_cli(cmd, args.owner, args.repo) + return True + except Exception as exc: # noqa: BLE001 + print(f"[fail] {label}: {exc}", file=sys.stderr) + return False + + +def _publish_report(args, plan) -> None: + """R3:把 report_md 作为评论发到指定 issue。""" + body = (plan.get("report_md") or "").strip() + issue = getattr(args, "report_issue", None) + if not body or not issue: + return + _exec_or_dry(args, ["issue", "+comment", "--number", str(issue), "--body", body], + f"report→issue#{issue}") + + +def _publish_release_notes(args, plan) -> None: + """R4:把 release_notes_md 合并进指定 release 的 body(release +update --body 整体替换,先读后拼)。""" + body = (plan.get("release_notes_md") or "").strip() + rid = getattr(args, "release_id", None) + if not body or not rid: + return + existing = "" + if args.apply: + try: + rv = run_gitlink_cli(["release", "+view", "--id", str(rid)], args.owner, args.repo) + data = rv.get("data", rv) if isinstance(rv, dict) else {} + existing = str(first_value(data, ("body", "description", "content", "version_content"), "") or "").rstrip() + except Exception as exc: # noqa: BLE001 + print(f"[warn] read release body failed: {exc}", file=sys.stderr) + merged = (existing + "\n\n" + body) if existing else body + _exec_or_dry(args, ["release", "+update", "--id", str(rid), "--body", merged], + f"notes→release#{rid}") + + +def cmd_apply(args) -> int: + plan = _read_json(Path(args.plan)) + writes = plan.get("writes", []) + done = 0 + for w in writes: + cmd = _build_command(w) + if cmd is None: + continue + if _exec_or_dry(args, cmd, f"#{w.get('issue')} {w.get('op')}"): + done += 1 + _publish_report(args, plan) + _publish_release_notes(args, plan) + _publish_report_to_wiki(args, plan) + n = done if args.apply else len(writes) + print(f"{'applied' if args.apply else 'dry-run'}: {n} write(s)") + return 0 + + +# --------------------------------------------------------------------------- # +# Wiki 发布(R3-wiki:周报上传到 Wiki 周报目录) +# --------------------------------------------------------------------------- # +_WIKI_CLI_BIN = "gitlink-cli" + + +def _find_wiki_cli() -> str: + """找到 gitlink-cli 可执行文件;优先复用 _gw 的 which 逻辑。""" + if _gw is not None and hasattr(_gw, "shutil_which"): + path = _gw.shutil_which(_WIKI_CLI_BIN) + if path: + return path + from shutil import which + return which(_WIKI_CLI_BIN) or _WIKI_CLI_BIN + + +def _run_wiki(args, command: list[str], label: str) -> bool: + """执行 gitlink-cli wiki 子命令;dry-run 打印,--apply 真写。返回是否实际执行。""" + cli = _find_wiki_cli() + cmd = [cli, "wiki", *command, "--owner", args.owner, "--repo", args.repo] + if not args.apply: + print(f"[dry-run] {label}: {' '.join(cmd)}") + return False + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if proc.returncode != 0: + err = proc.stderr.strip() or proc.stdout.strip() or "未知错误" + print(f"[fail] {label}: {err}", file=sys.stderr) + return False + print(f"[ok] {label}") + return True + except Exception as exc: # noqa: BLE001 + print(f"[fail] {label}: {exc}", file=sys.stderr) + return False + + +def _ensure_wiki_dir(args, dir_name: str) -> bool: + """确保 Wiki 侧边栏中存在目录;不存在则创建。""" + # 先检查目录是否已存在(通过读 Sidebar 判断) + cli = _find_wiki_cli() + check_cmd = [cli, "wiki", "+view", "--name", "_Sidebar", + "--owner", args.owner, "--repo", args.repo] + try: + proc = subprocess.run(check_cmd, capture_output=True, text=True, timeout=15) + if proc.returncode == 0 and f"- {dir_name}" not in proc.stdout: + return _run_wiki(args, ["+mkdir", "--name", dir_name], + f"wiki mkdir {dir_name}") + except Exception: + pass + return True # 无法判断时跳过,让 create --dir 自行报错 + + +def _publish_report_to_wiki(args, plan) -> None: + """R3-wiki:把周报上传到 Wiki 周报目录(支持幂等:同日期运行更新而非报错)。""" + body = (plan.get("report_md") or "").strip() + if not body: + return + if not getattr(args, "publish_wiki", False): + return + + wiki_dir = getattr(args, "wiki_dir", None) or "周报专区" + now = datetime.now(timezone.utc) + page_name = f"周报-{now.strftime('%Y-%m-%d-%H%M')}" + commit_msg = f"自动生成周报 {now.strftime('%Y-%m-%d %H:%M')}" + + # Step 1: 确保目录存在 + _ensure_wiki_dir(args, wiki_dir) + + # Step 2: 尝试创建 → 失败则更新(幂等) + ok = _run_wiki(args, [ + "+create", "--name", page_name, + "--content", body, + "--message", commit_msg, + "--dir", wiki_dir, + ], f"wiki create {page_name} → {wiki_dir}") + if not ok: + _run_wiki(args, [ + "+update", "--name", page_name, + "--content", body, + "--message", commit_msg, + ], f"wiki update {page_name} (fallback)") + + +def cmd_checkpoint(args) -> int: + now = datetime.now(timezone.utc) + Path(args.checkpoint).parent.mkdir(parents=True, exist_ok=True) + Path(args.checkpoint).write_text(now.isoformat(), encoding="utf-8") + log = Path(args.triage_log) + entry = _read_json(Path(args.plan)) if args.plan and Path(args.plan).exists() else {"writes": []} + batch = [ + f"\n## 批次 {now.isoformat()}({args.owner}/{args.repo})\n", + f"- writes: {len(entry.get('writes', []))}\n", + ] + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("a", encoding="utf-8") as fh: + fh.writelines(batch) + print(f"checkpoint → {args.checkpoint} ({now.isoformat()})") + return 0 + + +def _generate_report(candidates: dict, plan: dict | None = None) -> str: + """生成七段式社区运营周报(纯 Markdown 文本拼接,非 HTML 渲染)。 + + Wiki 平台负责将 Markdown 转为页面展示,我们只负责数据→内容的编排。 + """ + repo = candidates.get("repo", "") + owner = candidates.get("owner", "") + since_str = candidates.get("since", "")[:10] + now = datetime.now(timezone.utc) + week_end = now.strftime("%Y-%m-%d") + week_range = f"{since_str} ~ {week_end}" + + trends = candidates.get("trends", {}) or {} + current = candidates.get("current_counts", {}) or {} + contributors = candidates.get("contributors", []) or [] + writes = (plan or {}).get("writes", []) or [] + + issues = candidates.get("issues", []) or [] + merged_prs = candidates.get("merged_prs", []) or [] + open_prs = candidates.get("open_prs", []) or [] + + lines: list[str] = [] + + # ── 标题 ── + lines.append(f"# {repo} 社区运营周报") + lines.append("") + lines.append(f"> 📅 {week_range} | 🤖 由 community-ops-sweep 自动生成") + lines.append("") + + # ── 一、执行摘要 ── + lines.append("## 一、📊 执行摘要") + lines.append("") + summary_placeholder = candidates.get("executive_summary", "").strip() + if summary_placeholder: + lines.append(summary_placeholder) + else: + lines.append("> ⚠️ 执行摘要未生成。运行 Workflow 时由 LLM Agent 自动填充。") + lines.append("") + + # ── 二、核心指标 ── + lines.append("## 二、📈 核心指标") + lines.append("") + lines.append("| 指标 | 本周数值 | 趋势 |") + lines.append("|------|:--------:|:----:|") + _metric_row(lines, "Issue 总数", current, "issues_total", trends) + _metric_row(lines, "打开 Issue", current, "issues_open", trends) + _metric_row(lines, "已关闭 Issue", current, "issues_closed", trends) + _metric_row(lines, "待审 PR", current, "prs_open", trends) + _metric_row(lines, "已合并 PR", current, "prs_merged", trends) + _metric_row(lines, "活跃贡献者", current, "contributors", trends) + lines.append("") + + # ── 三、新增 Issue 清单 ── + lines.append("## 三、🆕 新增 Issue 清单") + lines.append("") + if issues: + lines.append("| 编号 | 标题 | 标签 | 提交者 |") + lines.append("|------|------|------|--------|") + for i in issues: + num = i.get("number", "-") + title = (i.get("title") or "(untitled)")[:60] + labels = ", ".join(i.get("labels", [])[:4]) or "-" + author = i.get("author_login", "-") + lines.append(f"| #{num} | {title} | {labels} | @{author} |") + else: + lines.append("本周无新增 Issue。") + lines.append("") + + # ── 四、PR 活动 ── + lines.append("## 四、🔀 PR 活动") + lines.append("") + + lines.append(f"### 已合并 PR({len(merged_prs)} 个)") + if merged_prs: + lines.append("") + lines.append("| 编号 | 标题 | 作者 | 合并时间 |") + lines.append("|------|------|------|----------|") + for p in merged_prs[:15]: + num = p.get("number", "-") + title = (p.get("title") or "(untitled)")[:60] + author = p.get("author_login", "-") + merged_at = _fmt_dt(p.get("merged_at") or p.get("updated_at")) + lines.append(f"| !{num} | {title} | @{author} | {merged_at} |") + else: + lines.append("") + lines.append("本周无合并 PR。") + lines.append("") + + lines.append(f"### 待审 PR({len(open_prs)} 个)") + if open_prs: + lines.append("") + lines.append("| 编号 | 标题 | 作者 | 创建时间 |") + lines.append("|------|------|------|----------|") + for p in open_prs[:15]: + num = p.get("number", "-") + title = (p.get("title") or "(untitled)")[:60] + author = p.get("author_login", "-") + created = _fmt_dt(p.get("created_at")) + lines.append(f"| !{num} | {title} | @{author} | {created} |") + else: + lines.append("") + lines.append("无待审 PR 🎉") + lines.append("") + + # ── 五、贡献者活跃榜 ── + lines.append("## 五、👥 贡献者活跃榜") + lines.append("") + if contributors: + lines.append("| 排名 | 贡献者 | Issue 提交 | PR 合并 | 活跃度 |") + lines.append("|:----:|--------|:----------:|:-------:|:------:|") + for idx, c in enumerate(contributors, 1): + score = c["issues"] + c["prs"] + bar = "█" * min(score, 10) + lines.append(f"| {idx} | @{c['login']} | {c['issues']} | {c['prs']} | {bar} |") + else: + lines.append("本周无活跃贡献者。") + lines.append("") + + # ── 六、自动 Triage 日志 ── + lines.append("## 六、🤖 自动 Triage 日志") + lines.append("") + if writes: + lines.append("| Issue | 操作 | 详情 |") + lines.append("|-------|------|------|") + for w in writes: + issue_num = f"#{w.get('issue', '-')}" + op = w.get("op", "-") + detail = _triage_detail(w) + lines.append(f"| {issue_num} | {op} | {detail} |") + else: + lines.append("本次 sweep 未执行写操作(无变更或 dry-run)。") + lines.append("") + + # ── 七、待办与风险 ── + lines.append("## 七、⚠️ 待办与风险") + lines.append("") + stale_issues = [i for i in issues if _is_stale(i, since_str)] + stale_prs = [p for p in open_prs if _is_stale_pr(p, since_str)] + if stale_issues: + lines.append(f"- 🔴 **{len(stale_issues)} 个 Issue** 超过窗口期未更新,建议优先处理") + if stale_prs: + lines.append(f"- 🟡 **{len(stale_prs)} 个 PR** 等待 Review 超过窗口期,建议推进") + if open_prs: + lines.append(f"- 📋 **{len(open_prs)} 个 PR** 待 Review,建议分配 Reviewer") + if not stale_issues and not stale_prs: + lines.append("- ✅ 当前无超期未处理的 Issue 或 PR。") + lines.append("") + + lines.append("---") + lines.append("") + lines.append(f"*报告生成时间:{now.strftime('%Y-%m-%d %H:%M:%S UTC')} | 数据来源:[{owner}/{repo}](https://gitlink.org.cn/{owner}/{repo})*") + lines.append("") + + return "\n".join(lines) + + +def _metric_row(lines: list[str], label: str, counts: dict, key: str, trends: dict): + val = counts.get(key, "-") + trend = trends.get(key, "→") + lines.append(f"| {label} | {val} | {trend} |") + + +def _fmt_dt(dt) -> str: + if isinstance(dt, datetime): + return dt.strftime("%m-%d") + if isinstance(dt, str): + return dt[:10] + return "-" + + +def _is_stale(issue: dict, since_str: str) -> bool: + updated = issue.get("updated_at") + if isinstance(updated, datetime): + return updated.strftime("%Y-%m-%d") < since_str + if isinstance(updated, str): + return updated[:10] < since_str + return False + + +def _is_stale_pr(pr: dict, since_str: str) -> bool: + updated = pr.get("updated_at") + if isinstance(updated, datetime): + return updated.strftime("%Y-%m-%d") < since_str + if isinstance(updated, str): + return updated[:10] < since_str + return False + + +def _triage_detail(w: dict) -> str: + op = w.get("op", "") + if op == "update_tags": + added = ", ".join(w.get("tag_names", [])) + removed = ", ".join(w.get("remove_tag_names", [])) + parts = [] + if added: + parts.append(f"+{added}") + if removed: + parts.append(f"-{removed}") + return " ".join(parts) if parts else "标签调整" + if op == "update_status": + return f"→ {w.get('state', '?')} ({w.get('reason', '')})" + if op == "comment": + kind = w.get("kind", "") + return {"owner_suggest": "建议负责人", "link_close": "自动关闭通知"}.get(kind, "评论") + if op == "update_assigner": + return f"指派 {', '.join(w.get('assigner_ids', []))}" + return "-" + + +def _render_release_notes(candidates: dict) -> str: + if _gw is None: + return "" + try: + repo_info = {"name": candidates.get("repo", ""), "description": ""} + issues = [coerce_dates(i) for i in candidates.get("issues", [])] + prs = [coerce_dates(p, ("updated_at", "created_at", "merged_at")) for p in candidates.get("merged_prs", [])] + summary = _gw.summarize_workflow( + repo_info, issues, prs, [], + datetime.now(timezone.utc), 7, + ) + return _gw.render_release_notes(summary) + except Exception as exc: # noqa: BLE001 + print(f"[warn] render_release_notes failed: {exc}", file=sys.stderr) + return "" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="community-ops-sweep 确定性引擎") + sub = p.add_subparsers(dest="cmd", required=True) + + pc = sub.add_parser("collect", help="采集 → candidates.json") + pc.add_argument("--owner", required=True); pc.add_argument("--repo", required=True) + pc.add_argument("--since"); pc.add_argument("--out", default="candidates.json") + pc.add_argument("--checkpoint", default=".last-sweep") + pc.set_defaults(func=cmd_collect) + + pp = sub.add_parser("plan", help="candidates + decisions → plan.json") + pp.add_argument("--candidates", default="candidates.json") + pp.add_argument("--triage"); pp.add_argument("--owners"); pp.add_argument("--links") + pp.add_argument("--summary", help="LLM 生成的执行摘要 JSON 文件") + pp.add_argument("--routing"); pp.add_argument("--policy") + pp.add_argument("--out", default="plan.json") + pp.set_defaults(func=cmd_plan) + + pa = sub.add_parser("apply", help="执行 plan.json(默认 dry-run)") + pa.add_argument("--plan", default="plan.json") + pa.add_argument("--owner", required=True); pa.add_argument("--repo", required=True) + pa.add_argument("--apply", action="store_true") + pa.add_argument("--report-issue", dest="report_issue", help="R3:把周报作为评论发到此 issue") + pa.add_argument("--release-id", dest="release_id", help="R4:把 release notes 合并进此 release body") + pa.add_argument("--publish-wiki", action="store_true", dest="publish_wiki", + help="R3-wiki:把周报上传到 Wiki 周报目录") + pa.add_argument("--wiki-dir", dest="wiki_dir", default="周报专区", + help="Wiki 周报目录名(默认:周报专区)") + pa.set_defaults(func=cmd_apply) + + pk = sub.add_parser("checkpoint", help="推进 .last-sweep + 追加 triage-log") + pk.add_argument("--owner", required=True); pk.add_argument("--repo", required=True) + pk.add_argument("--checkpoint", default=".last-sweep") + pk.add_argument("--triage-log", default="docs/issue-triage-log.md") + pk.add_argument("--plan") + pk.set_defaults(func=cmd_checkpoint) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workflows/community-ops-sweep/tests/test_sweep.py b/workflows/community-ops-sweep/tests/test_sweep.py new file mode 100644 index 0000000..3fdd637 --- /dev/null +++ b/workflows/community-ops-sweep/tests/test_sweep.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""community_ops_sweep 引擎纯逻辑单测 —— 把"同输入→同 plan"钉成可回归事实。 + +只测确定性纯函数(标签增删 / 状态映射 / 链接校验 / plan 组装),不碰 gitlink-cli I/O。 +纯标准库 unittest(与 gatekeeper test_scoring.py 同风格)。 + +运行: + python3 examples/workflows/community-ops-sweep/tests/test_sweep.py +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path + +# 以绝对路径加载被测脚本(非包,按文件载入;先注册 sys.modules 以兼容 dataclass+annotations) +_SCRIPT = ( + Path(__file__).resolve().parent.parent / "scripts" / "community_ops_sweep.py" +) +_spec = importlib.util.spec_from_file_location("community_ops_sweep", _SCRIPT) +assert _spec and _spec.loader, f"无法定位被测脚本:{_SCRIPT}" +sw = importlib.util.module_from_spec(_spec) +sys.modules["community_ops_sweep"] = sw +_spec.loader.exec_module(sw) # type: ignore[union-attr] + +TAG_MAP = {"bug": "3", "feature": "5", "docs": "7", "question": "9", "enhancement": "11"} +POLICY = { + "status_map": {"duplicate": "closed", "answered": "closed", "spam": "closed"}, +} + + +class TestMergeTagIds(unittest.TestCase): + def test_add_to_empty(self): + self.assertEqual(sw.merge_tag_ids([], ["bug"], [], TAG_MAP), ["3"]) + + def test_add_preserves_existing_union(self): + # 整体替换语义:现有 ∪ 新增 + self.assertEqual(sw.merge_tag_ids(["3", "7"], ["feature"], [], TAG_MAP), ["3", "5", "7"]) + + def test_remove_drops_tag(self): + self.assertEqual(sw.merge_tag_ids(["3", "7"], [], ["bug"], TAG_MAP), ["7"]) + + def test_add_and_remove_together(self): + # current[3,7] ∪ {5} \ {7} = {3,5} + self.assertEqual(sw.merge_tag_ids(["3", "7"], ["feature"], ["docs"], TAG_MAP), ["3", "5"]) + + def test_unknown_add_filtered_by_whitelist(self): + self.assertEqual(sw.merge_tag_ids([], ["nonexistent"], [], TAG_MAP), []) + + def test_result_sorted_deduped(self): + self.assertEqual(sw.merge_tag_ids(["7", "3"], ["bug"], [], TAG_MAP), ["3", "7"]) + + +class TestMapStatus(unittest.TestCase): + def test_duplicate_closes(self): + self.assertEqual(sw.map_status("duplicate", POLICY), "closed") + + def test_unknown_action_no_change(self): + self.assertIsNone(sw.map_status("schedule_fix", POLICY)) + + def test_none_action_no_change(self): + self.assertIsNone(sw.map_status(None, POLICY)) + + +class TestBuildPlanTriage(unittest.TestCase): + def _candidates(self, issues): + return {"issues": issues, "merged_prs": [], "open_issues": {i["number"] for i in issues}} + + def test_plan_adds_tag(self): + candidates = self._candidates([{"number": "42", "title": "登录失败", "description": "", "tags": [], "state": "open"}]) + triage = [{"issue": "42", "tag_names": ["bug"], "remove_tag_names": [], "recommended_action": "schedule_fix"}] + plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP) + tag_writes = [w for w in plan["writes"] if w.get("issue") == "42" and w["op"] == "update_tags"] + self.assertTrue(any(w["tag_ids"] == ["3"] for w in tag_writes), plan["writes"]) + + def test_plan_removes_tag(self): + candidates = self._candidates([{"number": "42", "title": "x", "description": "", "tags": [{"id": "3", "name": "bug"}], "state": "open"}]) + triage = [{"issue": "42", "tag_names": [], "remove_tag_names": ["bug"], "recommended_action": "schedule_fix"}] + plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP) + tag_writes = [w for w in plan["writes"] if w.get("issue") == "42" and w["op"] == "update_tags"] + # 期望全集为空 → tag_ids == [] + self.assertTrue(any(w["tag_ids"] == [] for w in tag_writes), plan["writes"]) + + def test_plan_closes_duplicate(self): + candidates = self._candidates([{"number": "43", "title": "dup", "description": "", "tags": [], "state": "open"}]) + triage = [{"issue": "43", "tag_names": [], "remove_tag_names": [], "recommended_action": "duplicate"}] + plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP) + status_writes = [w for w in plan["writes"] if w.get("issue") == "43" and w["op"] == "update_status"] + self.assertTrue(any(w["state"] == "closed" for w in status_writes), plan["writes"]) + + +class TestBuildPlanLinks(unittest.TestCase): + def test_link_closes_only_open_existing_issue(self): + candidates = { + "issues": [], + "merged_prs": [{"number": "128", "title": "fix login", "description": "fixes #45", "author": "alice"}], + "open_issues": {"45"}, # 45 开着;99 不在 → 不存在/未开 + } + links = [{"pr": "128", "linked_issue_numbers": ["45", "99"]}] + plan = sw.build_plan(candidates, [], [], links, {}, POLICY, TAG_MAP) + closes = [w for w in plan["writes"] if w["op"] == "update_status" and w.get("state") == "closed"] + closed = {w["issue"] for w in closes} + self.assertIn("45", closed) + self.assertNotIn("99", closed) + + def test_link_no_match_no_write(self): + candidates = {"issues": [], "merged_prs": [{"number": "1", "title": "x", "description": "", "author": "a"}], "open_issues": set()} + links = [{"pr": "1", "linked_issue_numbers": []}] + plan = sw.build_plan(candidates, [], [], links, {}, POLICY, TAG_MAP) + self.assertFalse(any(w["op"] == "update_status" for w in plan["writes"])) + + +class TestBuildPlanOwnerSuggest(unittest.TestCase): + def test_owner_suggestion_emits_comment_not_assign(self): + # R2 默认建议:写 comment,不写 assign + candidates = {"issues": [{"number": "50", "title": "t", "description": "", "tags": [], "state": "open"}], "merged_prs": [], "open_issues": {"50"}} + owners = [{"issue": "50", "suggested_owners": ["alice", "bob"], "evidence": "近期 PR#128"}] + plan = sw.build_plan(candidates, [], owners, [], {}, POLICY, TAG_MAP) + comments = [w for w in plan["writes"] if w.get("issue") == "50" and w["op"] == "comment"] + assigns = [w for w in plan["writes"] if w.get("issue") == "50" and w["op"] == "update_assigner"] + self.assertTrue(comments, "应有建议评论") + self.assertFalse(assigns, "默认不应自动指派") + + +class TestCoerceDates(unittest.TestCase): + """candidates.json 经磁盘 round-trip 后日期变字符串;渲染前要 coerce 回 datetime。""" + + def test_parses_string_dates(self): + out = sw.coerce_dates({"updated_at": "2026-06-15T10:00:00+00:00", "created_at": "2026-06-10T00:00:00+00:00", "x": 1}) + self.assertIsInstance(out["updated_at"], datetime) + self.assertIsInstance(out["created_at"], datetime) + self.assertEqual(out["x"], 1) + + def test_datetime_and_none_passthrough(self): + dt = datetime(2026, 6, 1, tzinfo=timezone.utc) + out = sw.coerce_dates({"updated_at": dt, "created_at": None}) + self.assertIs(out["updated_at"], dt) + self.assertIsNone(out["created_at"]) + + def test_custom_keys(self): + out = sw.coerce_dates({"merged_at": "2026-06-15T10:00:00+00:00"}, keys=("merged_at",)) + self.assertIsInstance(out["merged_at"], datetime) + + +if __name__ == "__main__": + unittest.main(verbosity=2)