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自动驾驶
+
+
+
+
+
搜索驱动维度 · 基于关键词或分类发现仓库
+
+
+
+
+
+
目标仓库 · 填写后下方仓库维度才能执行定向分析
+
+
+ /
+
+
+
+
+
+
+
+
+
仓库驱动维度 · 对指定仓库做深度分析(需先填写仓库)
+
+
+
+
+
+
+ 至少选择一个分析维度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 · 智能化能力展示
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ 命令浏览器 (先选大方向 → 展开域 → 点动词填参数 → 真跑)
+ 左侧点分类展开命令域;点动词在右侧「参数构建」里填参(搜索类命令的 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)
+
+
③
输出层
输出符合 SKILL.md 模板(判定/评分/清单)
+
+
+
+
+
+
+
+
+
+
+
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 #** —
+ - 📁 涉及文件:``
+ - 📝 改动范围:约 行
+ - 💡 提示:<具体修改建议>
+ - 🔗 链接:https://www.gitlink.org.cn///issues/
+
+### ✅ 值得尝试(新人友好度:⭐⭐)
+
+2. **Issue #** —
+ - 📝 需要了解:<相关知识>
+ - 💡 提示:<学习建议>
+```
+
+---
+
+### 工作流 3:Issue 引导评论生成
+
+**场景**:项目维护者希望为 good-first-issue 自动生成引导评论,帮助新人快速上手。
+
+#### Step 1:获取 Issue 详情
+
+```bash
+# 查看 Issue 详情
+gitlink-cli issue +view --number --format json
+
+# 获取相关文件内容(用于代码定位)—— 原始文件读取暂未封装 Shortcut,用 Raw API
+gitlink-cli api GET ///raw/master/
+```
+
+#### Step 2:生成引导评论
+
+AI 根据 Issue 内容生成结构化的引导评论:
+
+```markdown
+## 🌟 欢迎贡献!
+
+感谢你对本项目的关注!这是一个 **good first issue**,非常适合首次贡献者。
+
+### 📋 任务描述
+<用自己的话重述 Issue 内容>
+
+### 🗺️ 代码定位
+- 需要修改的文件:``
+- 相关函数/类:``(第 行附近)
+- 依赖的上下文:``
+
+### ✏️ 修改步骤
+1. **Fork 项目**
+ ```bash
+ gitlink-cli repo +fork --owner --repo
+ ```
+2. **创建分支**
+ ```bash
+ git checkout -b fix/