forked from Gitlink/gitlink-cli
feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组 #49
|
|
@ -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
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.devops
|
||||
.github
|
||||
node_modules
|
||||
dist
|
||||
doc
|
||||
npm
|
||||
*.md
|
||||
*.exe
|
||||
.gitignore
|
||||
.golangci.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 .
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
|
||||
gitlink-cli.exe
|
||||
/gitlink-cli
|
||||
docs/
|
||||
doc/
|
||||
# Python & demo artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
demo/bin/
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# ============================================================
|
||||
# 多阶段构建: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 安装依赖(利用层缓存),再拷源码
|
||||
COPY scripts/research/requirements.txt /app/scripts/research/requirements.txt
|
||||
RUN pip install --no-cache-dir -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"]
|
||||
28
Makefile
28
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 ./...
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
// Package server 提供子赛题四的「网页终端」演示服务(子赛题四·Phase 7)。
|
||||
//
|
||||
// 在云服务器上 `gitlink-cli server` 启动一个轻量 HTTP 服务:前端按 7 个科研场景按钮
|
||||
// 触发后端运行对应的 Python 算法脚本(scripts/research/*.py),并把 JSON / Mermaid /
|
||||
// 报告等产物回显/渲染。所有数据仍由 gitlink-cli 获取,本服务只做命令编排与产物转发。
|
||||
//
|
||||
// 安全:仅接受 7 个固定场景 + owner/repo/keyword 结构化参数(非任意 shell);
|
||||
// 若设置 --token / DEMO_TOKEN 环境变量,则请求需带 X-Demo-Token 头匹配。
|
||||
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},
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Port int
|
||||
ResearchDir string // scripts/research 目录
|
||||
WorkDir string // 产物输出根目录
|
||||
Token string // 可选鉴权 token
|
||||
}
|
||||
|
||||
func NewServerCmd() *cobra.Command {
|
||||
opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output"}
|
||||
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 环境变量)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Run 启动 HTTP 服务(阻塞)。
|
||||
func Run(opts Options) error {
|
||||
if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" {
|
||||
opts.Token = t
|
||||
}
|
||||
_ = 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)
|
||||
|
||||
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 != "")
|
||||
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)})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 == "") {
|
||||
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)
|
||||
}
|
||||
|
||||
// python3 优先,回退 python
|
||||
py, err := pythonBin()
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(py, argv...)
|
||||
cmd.Env = os.Environ()
|
||||
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)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>科研热点追踪 · GitLink Research Atlas</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
|
||||
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
|
||||
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
|
||||
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
|
||||
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
|
||||
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{min-height:100%}
|
||||
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
|
||||
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
|
||||
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
|
||||
-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:28px 36px 72px}
|
||||
|
||||
/* header */
|
||||
.hdr{display:flex;align-items:center;gap:14px;margin-bottom:22px}
|
||||
.back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--indigo);
|
||||
text-decoration:none;padding:7px 14px;border-radius:10px;background:var(--t-blue);white-space:nowrap}
|
||||
.back:hover{background:#D6DEFF}
|
||||
.hdr .titles h1{font-family:var(--serif);font-weight:700;font-size:30px;line-height:1.15}
|
||||
.hdr .titles .sub{font-size:13px;color:var(--mute);margin-top:3px}
|
||||
|
||||
/* control panel */
|
||||
.ctl{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
|
||||
padding:22px 24px;margin-bottom:22px}
|
||||
.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.row + .row{margin-top:14px}
|
||||
.row .lbl{font-size:12px;font-weight:600;color:var(--ink2);min-width:84px;letter-spacing:.02em}
|
||||
.search-input{flex:1;min-width:260px;display:flex;align-items:center;gap:8px;background:#F8FAFD;
|
||||
border:1px solid var(--line);border-radius:11px;padding:11px 14px}
|
||||
.search-input:focus-within{border-color:var(--indigo);background:#fff;box-shadow:0 0 0 3px rgba(79,107,237,.12)}
|
||||
.search-input input{border:0;outline:0;font-family:var(--mono);font-size:14px;width:100%;background:transparent;color:var(--ink)}
|
||||
.ex-chips{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.ex-chips .ex{font-size:12px;color:var(--ink2);background:#F1F5F9;border:1px solid var(--line);
|
||||
padding:5px 11px;border-radius:999px;cursor:pointer;transition:.15s}
|
||||
.ex-chips .ex:hover{background:var(--t-blue);border-color:#C7D2FE;color:var(--indigo)}
|
||||
.angles{display:flex;gap:9px;flex-wrap:wrap}
|
||||
.ang{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:500;padding:8px 14px;
|
||||
border-radius:11px;border:1px solid var(--line);background:#fff;cursor:pointer;transition:.15s;user-select:none}
|
||||
.ang .dot{width:9px;height:9px;border-radius:50%;background:var(--mute);transition:.15s}
|
||||
.ang.on{border-color:var(--indigo);background:#F5F8FF;color:var(--ink)}
|
||||
.ang.on .dot{background:var(--indigo);box-shadow:0 0 0 3px rgba(79,107,237,.18)}
|
||||
.ang[data-a="trending"] .dot{background:var(--coral)} .ang.on[data-a="trending"] .dot{background:var(--coral);box-shadow:0 0 0 3px rgba(242,107,94,.2)}
|
||||
.ang[data-a="active"] .dot{background:#F59E0B} .ang.on[data-a="active"] .dot{background:#F59E0B;box-shadow:0 0 0 3px rgba(245,158,11,.2)}
|
||||
.ang[data-a="topics"] .dot{background:#06B6D4} .ang.on[data-a="topics"] .dot{background:#06B6D4;box-shadow:0 0 0 3px rgba(6,182,212,.2)}
|
||||
.ang[data-a="scholars"] .dot{background:var(--mint)} .ang.on[data-a="scholars"] .dot{background:var(--mint);box-shadow:0 0 0 3px rgba(46,196,182,.2)}
|
||||
select.lim{font-family:var(--mono);font-size:13px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
|
||||
.btn-run{margin-left:auto;font-size:14px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--indigo),#7C8CF5);
|
||||
border:0;border-radius:11px;padding:11px 22px;cursor:pointer;box-shadow:var(--shadow-sm);transition:.15s}
|
||||
.btn-run:hover{filter:brightness(1.05);transform:translateY(-1px)}
|
||||
.btn-run:disabled{opacity:.6;cursor:not-allowed;transform:none}
|
||||
.status{font-family:var(--mono);font-size:12px;color:var(--mute);margin-top:14px;display:flex;align-items:center;gap:8px}
|
||||
.status .ok{color:var(--mint)} .status .err{color:var(--coral)}
|
||||
.spin{width:14px;height:14px;border-radius:50%;border:2px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
|
||||
@keyframes rot{to{transform:rotate(360deg)}}
|
||||
|
||||
/* summary banner */
|
||||
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}
|
||||
.sum-tile{border:1px solid var(--line);border-radius:13px;padding:14px 16px;background:var(--card);box-shadow:var(--shadow-sm)}
|
||||
.sum-tile .sl{font-size:11px;color:var(--mute);font-weight:600;letter-spacing:.02em}
|
||||
.sum-tile .sv{font-family:var(--mono);font-weight:700;font-size:26px;line-height:1.1;margin-top:4px;color:var(--ink)}
|
||||
.sum-tile .ss{font-size:11px;color:var(--mute);margin-top:2px}
|
||||
|
||||
/* sections */
|
||||
.sec{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
|
||||
padding:20px 24px;margin-top:18px}
|
||||
.sec-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
|
||||
.sec-head .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:15px}
|
||||
.sec-head h2{font-family:var(--serif);font-weight:700;font-size:21px}
|
||||
.sec-head .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);display:block;margin-top:1px}
|
||||
.sec-head .cnt{font-family:var(--mono);font-size:12px;color:var(--mute);margin-left:auto}
|
||||
.ic-coral{background:var(--t-coral);color:var(--coral)} .ic-amber{background:var(--t-amber);color:#B45309}
|
||||
.ic-cyan{background:#E0F7FA;color:#0891B2} .ic-mint{background:var(--t-mint);color:#0E7E72}
|
||||
|
||||
/* trending table */
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.tbl th{text-align:left;font-size:11px;color:var(--mute);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--line);text-transform:uppercase;letter-spacing:.04em}
|
||||
.tbl td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
.tbl tr:hover td{background:#F8FAFD}
|
||||
.tbl .repo{font-family:var(--mono);font-weight:600;color:var(--indigo)}
|
||||
.tbl .num{font-family:var(--mono);font-weight:700;text-align:right}
|
||||
.lang-pill{font-size:11px;padding:2px 8px;border-radius:6px;background:var(--t-blue);color:var(--indigo)}
|
||||
.scorebar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;margin-top:4px;min-width:70px}
|
||||
.scorebar i{display:block;height:100%;background:linear-gradient(90deg,var(--coral),#FF9B8F)}
|
||||
.kw-chip{display:inline-block;font-size:10px;font-weight:600;padding:2px 7px;border-radius:5px;background:var(--t-amber);color:#92400E;margin-right:3px}
|
||||
|
||||
/* active discussions */
|
||||
.disc{display:flex;flex-direction:column;gap:10px}
|
||||
.disc-item{display:flex;gap:12px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line);border-radius:11px;background:#FCFDFE}
|
||||
.disc-item .badge{font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0;margin-top:1px}
|
||||
.badge.issue{background:var(--t-blue);color:var(--indigo)} .badge.pr{background:var(--t-mint);color:#0E7E72}
|
||||
.disc-item .main{flex:1;min-width:0}
|
||||
.disc-item .ttl{font-size:13px;font-weight:500;line-height:1.4}
|
||||
.disc-item .meta{font-size:11px;color:var(--mute);margin-top:3px;font-family:var(--mono)}
|
||||
.disc-item .cmt{font-family:var(--mono);font-weight:700;color:#B45309;font-size:13px;flex-shrink:0}
|
||||
|
||||
/* topic bars */
|
||||
.tlist{display:flex;flex-direction:column;gap:11px}
|
||||
.trow{display:flex;align-items:center;gap:12px}
|
||||
.trow .tname{width:180px;font-size:13px;font-weight:500;font-family:var(--mono);color:var(--ink)}
|
||||
.trow .tbar{flex:1;height:24px;background:#F1F5F9;border-radius:7px;overflow:hidden}
|
||||
.trow .tbar i{display:block;height:100%;background:linear-gradient(90deg,#06B6D4,#22D3EE);border-radius:7px}
|
||||
.trow .tval{font-family:var(--mono);font-weight:700;font-size:13px;width:48px;text-align:right}
|
||||
|
||||
/* scholars grid */
|
||||
.sgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||
.scard{border:1px solid var(--line);border-radius:11px;padding:13px 15px;background:#FCFDFE}
|
||||
.scard .nm{font-family:var(--mono);font-weight:600;color:var(--ink);font-size:13px}
|
||||
.scard .ct{font-size:12px;color:var(--mute);margin-top:3px}
|
||||
.scard.org{border-left:3px solid var(--indigo)}
|
||||
|
||||
/* repos network mini-list */
|
||||
.rlist{display:flex;flex-direction:column;gap:6px}
|
||||
.rlist .rli{font-family:var(--mono);font-size:11px;color:var(--mute);padding:2px 0}
|
||||
|
||||
/* empty / loading / placeholder */
|
||||
.empty{padding:50px 20px;text-align:center;color:var(--mute);font-size:14px}
|
||||
.placeholder{margin-top:18px;padding:60px 20px;text-align:center;color:var(--mute);background:var(--card);
|
||||
border:1px dashed var(--line);border-radius:16px;font-size:14px}
|
||||
.placeholder b{color:var(--ink2)}
|
||||
|
||||
/* skeleton loading */
|
||||
@keyframes shimmer{0%{background-position:-400px 0}100%{background-position:400px 0}}
|
||||
.skel{background:linear-gradient(90deg,#F1F5F9 25%,#E6EBF2 50%,#F1F5F9 75%);background-size:800px 100%;animation:shimmer 1.5s infinite;border-radius:8px}
|
||||
|
||||
/* responsive */
|
||||
@media(max-width:760px){
|
||||
.wrap{padding:16px}
|
||||
.summary{grid-template-columns:repeat(2,1fr)}
|
||||
.sgrid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
||||
.row .lbl{min-width:60px;font-size:11px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hdr">
|
||||
<a class="back" href="index.html">← 仪表盘</a>
|
||||
<div class="titles">
|
||||
<h1>科研热点追踪</h1>
|
||||
<div class="sub">输入领域/关键词,勾选追踪角度,扫描 GitLink 科研仓库 → 飙升项目 · 活跃讨论 · 主题热度 · 学者团队</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- control panel -->
|
||||
<div class="ctl">
|
||||
<div class="row">
|
||||
<span class="lbl">领域 / 关键词</span>
|
||||
<label class="search-input"><span>🔍</span>
|
||||
<input id="keyword" value="deep learning,机器学习" placeholder='输入研究领域或技术关键词,逗号分隔多个(如 "计算机视觉,NLP,transformer")'>
|
||||
</label>
|
||||
<button class="btn-run" id="runBtn">开始追踪 ▶</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="lbl">示例</span>
|
||||
<div class="ex-chips" id="exChips">
|
||||
<span class="ex">深度学习</span><span class="ex">computer vision</span>
|
||||
<span class="ex">自然语言处理</span><span class="ex">强化学习</span>
|
||||
<span class="ex">knowledge graph</span><span class="ex">联邦学习</span>
|
||||
<span class="ex">LLM agent</span><span class="ex">自动驾驶</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="lbl">追踪角度</span>
|
||||
<div class="angles" id="angles">
|
||||
<span class="ang on" data-a="trending"><span class="dot"></span>🔥 飙升项目</span>
|
||||
<span class="ang on" data-a="active"><span class="dot"></span>💬 活跃讨论</span>
|
||||
<span class="ang on" data-a="topics"><span class="dot"></span>📊 热门主题</span>
|
||||
<span class="ang on" data-a="scholars"><span class="dot"></span>👥 核心学者·团队</span>
|
||||
</div>
|
||||
<span class="lbl" style="margin-left:auto">仓库上限</span>
|
||||
<select class="lim" id="lim">
|
||||
<option>5</option><option>8</option><option selected>12</option><option>20</option><option>30</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="status" id="status"><span>就绪 · 点"开始追踪"扫描 GitLink</span></div>
|
||||
</div>
|
||||
|
||||
<!-- summary tiles (hidden until first run) -->
|
||||
<div class="summary" id="summary" style="display:none"></div>
|
||||
|
||||
<!-- results -->
|
||||
<div id="results">
|
||||
<div class="placeholder">输入关键词并选择角度,点 <b>开始追踪</b> —— 服务器将通过 gitlink-cli 实时搜索并构建该领域的科研热点全景。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ========== 工具函数 ========== */
|
||||
function esc(s){return String(s||"").replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||
function token(){return localStorage.getItem("demo_token")||"";}
|
||||
|
||||
/* ========== angle 切换 + 示例芯片 ========== */
|
||||
document.querySelectorAll(".ang").forEach(a=>a.onclick=()=>a.classList.toggle("on"));
|
||||
document.querySelectorAll(".ex").forEach(e=>e.onclick=()=>{
|
||||
document.getElementById("keyword").value=e.textContent.trim();
|
||||
});
|
||||
|
||||
/* ========== 状态栏 ========== */
|
||||
function setStatus(html,cls){
|
||||
const s=document.getElementById("status");
|
||||
s.innerHTML=html;
|
||||
s.className="status"+(cls?" "+cls:"");
|
||||
}
|
||||
|
||||
/* ========== 主运行 ========== */
|
||||
async function run(){
|
||||
const kw=document.getElementById("keyword").value.trim();
|
||||
if(!kw){setStatus("请输入关键词","err");return;}
|
||||
|
||||
const angles=[...document.querySelectorAll(".ang.on")].map(a=>a.dataset.a);
|
||||
if(!angles.length){setStatus("请至少选择一个追踪角度","err");return;}
|
||||
|
||||
const btn=document.getElementById("runBtn");
|
||||
btn.disabled=true;
|
||||
|
||||
setStatus('<span class="spin"></span> 扫描中:搜索 "'+esc(kw)+'" → 拉取仓库详情 → 批量获取 Issue/PR 讨论…(约 30–90s,取决于仓库数和 API 响应速度)');
|
||||
document.getElementById("results").innerHTML="";
|
||||
document.getElementById("summary").style.display="none";
|
||||
|
||||
try{
|
||||
const r=await fetch("/api/run",{
|
||||
method:"POST",
|
||||
headers:{"Content-Type":"application/json","X-Demo-Token":token()},
|
||||
body:JSON.stringify({scenario:"hotspot",keyword:kw})
|
||||
});
|
||||
const j=await r.json();
|
||||
|
||||
if(!j.ok){
|
||||
const errMsg=j.error||j.stdout||"未知错误";
|
||||
setStatus("✗ 运行失败: "+esc(errMsg)+" · "+(j.duration||""),"err");
|
||||
btn.disabled=false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
let obj=null;
|
||||
try{
|
||||
const raw=(j.artifacts&&j.artifacts.json)||(j.stdout||"");
|
||||
obj=typeof raw==="string"?JSON.parse(raw):raw;
|
||||
}catch(e){
|
||||
setStatus("✗ 解析 JSON 产物失败 · 可能是 Python 脚本异常: "+(e.message||""),"err");
|
||||
btn.disabled=false;
|
||||
return;
|
||||
}
|
||||
|
||||
if(!obj||typeof obj!=="object"){
|
||||
setStatus("✗ 返回数据为空 · 请确认目标关键词在 GitLink 上有结果","err");
|
||||
btn.disabled=false;
|
||||
return;
|
||||
}
|
||||
|
||||
const meta=obj.meta||{};
|
||||
setStatus(
|
||||
"✓ 完成 · "+(j.duration||"")+
|
||||
" · 仓库 "+meta.repo_count+" 个"+
|
||||
" · 讨论 "+meta.discussion_count+" 条"+
|
||||
" · 主题 "+meta.topic_count+" 个"+
|
||||
" · 学者 "+meta.scholar_count+" 位",
|
||||
"ok"
|
||||
);
|
||||
|
||||
// 汇总卡片
|
||||
renderSummary(meta);
|
||||
// 结果
|
||||
render(angles,obj);
|
||||
|
||||
}catch(e){
|
||||
setStatus("✗ 请求失败: "+esc(e.message||e),"err");
|
||||
}finally{
|
||||
btn.disabled=false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 汇总卡片 ========== */
|
||||
function renderSummary(meta){
|
||||
const el=document.getElementById("summary");
|
||||
el.style.display="grid";
|
||||
el.innerHTML=
|
||||
tile("indigo","扫描仓库",meta.repo_count||0,"个")+
|
||||
tile("coral","活跃讨论",meta.discussion_count||0,"条")+
|
||||
tile("cyan","热门主题",meta.topic_count||0,"个")+
|
||||
tile("mint","核心学者",meta.scholar_count||0,"位");
|
||||
}
|
||||
function tile(c,label,val,unit){
|
||||
const colors={indigo:"var(--indigo)",coral:"var(--coral)",cyan:"#06B6D4",mint:"var(--mint)"};
|
||||
const bg={indigo:"var(--t-blue)",coral:"var(--t-coral)",cyan:"#E0F7FA",mint:"var(--t-mint)"};
|
||||
return `<div class="sum-tile" style="border-top:3px solid ${colors[c]||colors.indigo}">
|
||||
<div class="sl">${label}</div><div class="sv">${val}</div><div class="ss">${unit}</div></div>`;
|
||||
}
|
||||
|
||||
/* ========== 渲染各个角度 ========== */
|
||||
function render(angles,o){
|
||||
const box=document.getElementById("results");
|
||||
let h="";
|
||||
const maxScore=Math.max(1,...(o.trending_repos||[]).map(r=>r.score||0));
|
||||
|
||||
// --- 🔥 飙升项目 ---
|
||||
if(angles.includes("trending")){
|
||||
const tr=o.trending_repos||[];
|
||||
h+=`<div class="sec">
|
||||
<div class="sec-head">
|
||||
<div class="ico ic-coral">🔥</div>
|
||||
<div>
|
||||
<h2>飙升 / 热门项目</h2>
|
||||
<span class="eyebrow">Trending repos · 热度 = Star + Fork×2 + 近期更新加成</span>
|
||||
</div>
|
||||
<span class="cnt">${tr.length} 个</span>
|
||||
</div>`;
|
||||
if(tr.length){
|
||||
h+=`<table class="tbl"><thead><tr>
|
||||
<th>仓库</th><th>语言</th><th class="num">★ Star</th><th class="num">⑂ Fork</th><th class="num">日均★</th><th>热度</th><th>更新</th></tr></thead><tbody>`;
|
||||
tr.forEach(r=>{
|
||||
const kws=(r.matched_keywords||[]).map(k=>`<span class="kw-chip">${esc(k)}</span>`).join("");
|
||||
h+=`<tr>
|
||||
<td><div class="repo">${esc(r.repo)}</div>
|
||||
${kws?`<div style="margin-top:3px">${kws}</div>`:""}
|
||||
<div style="font-size:11px;color:var(--mute);margin-top:2px">${esc(r.description||"").slice(0,100)}</div></td>
|
||||
<td>${r.language?`<span class="lang-pill">${esc(r.language)}</span>`:"—"}</td>
|
||||
<td class="num">${r.stars}</td>
|
||||
<td class="num">${r.forks}</td>
|
||||
<td class="num">${r.velocity}</td>
|
||||
<td>
|
||||
<div class="scorebar"><i style="width:${Math.round(r.score/maxScore*100)}%"></i></div>
|
||||
<div style="font-size:11px;color:var(--mute);margin-top:2px;font-family:var(--mono)">${r.score}</div>
|
||||
</td>
|
||||
<td style="font-family:var(--mono);font-size:12px;color:var(--ink2)">${esc(r.updated||"—")}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table>`;
|
||||
}else{
|
||||
h+=`<div class="empty">未找到匹配仓库</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
}
|
||||
|
||||
// --- 💬 活跃讨论 ---
|
||||
if(angles.includes("active")){
|
||||
const ac=o.active_discussions||[];
|
||||
h+=`<div class="sec">
|
||||
<div class="sec-head">
|
||||
<div class="ico ic-amber">💬</div>
|
||||
<div>
|
||||
<h2>活跃讨论</h2>
|
||||
<span class="eyebrow">Active issues / PRs · 按评论数排序 · 来自所有扫描仓库</span>
|
||||
</div>
|
||||
<span class="cnt">${ac.length} 条</span>
|
||||
</div>`;
|
||||
if(ac.length){
|
||||
h+=`<div class="disc">`;
|
||||
ac.slice(0,30).forEach(a=>{
|
||||
h+=`<div class="disc-item">
|
||||
<span class="badge ${a.type}">${a.type==="pr"?"PR":"Issue"}</span>
|
||||
<div class="main">
|
||||
<div class="ttl">${esc(a.title||"(无标题)")}</div>
|
||||
<div class="meta">${esc(a.repo)} · #${esc(a.number||"")} · ${esc(a.state||"")}</div>
|
||||
</div>
|
||||
<span class="cmt">${a.comments} 💬</span>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
}else{
|
||||
h+=`<div class="empty">该领域暂无活跃讨论(所有仓库的开放 Issue/PR 评论数均为 0)</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
}
|
||||
|
||||
// --- 📊 热门主题 ---
|
||||
if(angles.includes("topics")){
|
||||
const tp=o.topic_heat||[];
|
||||
const maxT=Math.max(1,...tp.map(t=>t.count));
|
||||
h+=`<div class="sec">
|
||||
<div class="sec-head">
|
||||
<div class="ico ic-cyan">📊</div>
|
||||
<div>
|
||||
<h2>热门主题</h2>
|
||||
<span class="eyebrow">Topic heat · 在仓库描述/README 中命中的主题词频</span>
|
||||
</div>
|
||||
<span class="cnt">${tp.length} 个</span>
|
||||
</div>`;
|
||||
if(tp.length){
|
||||
h+=`<div class="tlist">`;
|
||||
tp.forEach(t=>{
|
||||
h+=`<div class="trow">
|
||||
<span class="tname">${esc(t.topic)}</span>
|
||||
<div class="tbar"><i style="width:${Math.round(t.count/maxT*100)}%"></i></div>
|
||||
<span class="tval">${t.count}</span>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
}else{
|
||||
h+=`<div class="empty">未识别到明确主题(仓库描述/README 不包含已知科研关键词)</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
}
|
||||
|
||||
// --- 👥 核心学者·团队 ---
|
||||
if(angles.includes("scholars")){
|
||||
const sc=o.core_scholars||[];
|
||||
const tm=o.core_teams||[];
|
||||
h+=`<div class="sec">
|
||||
<div class="sec-head">
|
||||
<div class="ico ic-mint">👥</div>
|
||||
<div>
|
||||
<h2>核心学者 · 团队</h2>
|
||||
<span class="eyebrow">Key scholars & teams · 按关联仓库数排列</span>
|
||||
</div>
|
||||
<span class="cnt">学者 ${(sc||[]).length} · 团队 ${(tm||[]).length}</span>
|
||||
</div>`;
|
||||
|
||||
// 学者
|
||||
if(sc.length){
|
||||
h+=`<div style="margin-bottom:14px"><div style="font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:8px">🧑 学者</div>`;
|
||||
h+=`<div class="sgrid">`;
|
||||
sc.slice(0,12).forEach(s=>{
|
||||
const repoNames=(s.repos||[]).slice(0,5);
|
||||
h+=`<div class="scard">
|
||||
<div class="nm">${esc(s.login)}</div>
|
||||
<div class="ct">关联 ${s.repo_count} 个仓库</div>
|
||||
${repoNames.length?`<div class="rlist">${repoNames.map(rn=>`<span class="rli">${esc(rn)}</span>`).join("")}</div>`:""}
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div></div>`;
|
||||
}
|
||||
|
||||
// 团队
|
||||
if(tm.length){
|
||||
h+=`<div><div style="font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:8px">🏛 活跃组织/团队</div>`;
|
||||
h+=`<div class="sgrid">`;
|
||||
tm.slice(0,8).forEach(t=>{
|
||||
h+=`<div class="scard org">
|
||||
<div class="nm">${esc(t.login)}</div>
|
||||
<div class="ct">${t.repo_count} 个仓库</div>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div></div>`;
|
||||
}
|
||||
|
||||
if(!sc.length&&!tm.length){
|
||||
h+=`<div class="empty">未识别到学者/团队(仓库无贡献者数据)</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
}
|
||||
|
||||
box.innerHTML=h||'<div class="empty" style="padding:70px 20px">无匹配角度的数据 —— 请选择至少一个追踪角度</div>';
|
||||
}
|
||||
|
||||
/* ========== 事件绑定 ========== */
|
||||
document.getElementById("runBtn").onclick=run;
|
||||
document.getElementById("keyword").addEventListener("keydown",e=>{if(e.key==="Enter")run();});
|
||||
|
||||
/* ========== 页面加载时尝试载入上次的 hotspot 结果 ========== */
|
||||
(async function init(){
|
||||
try{
|
||||
const r=await fetch("/api/result/hotspot",{headers:{"X-Demo-Token":token()}});
|
||||
const j=await r.json();
|
||||
if(j.ok&&j.artifacts&&j.artifacts.json){
|
||||
const obj=JSON.parse(j.artifacts.json);
|
||||
if(obj&&obj.trending_repos){
|
||||
const angles=[...document.querySelectorAll(".ang.on")].map(a=>a.dataset.a);
|
||||
const meta=obj.meta||{};
|
||||
renderSummary(meta);
|
||||
render(angles,obj);
|
||||
setStatus("✓ 已载入上次扫描 · 点"开始追踪"用新关键词重新扫描","ok");
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink Research Atlas · 科研代码图谱智能体</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
|
||||
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
|
||||
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
|
||||
--drawer:#111A2E;
|
||||
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
|
||||
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
|
||||
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;margin:0;overflow:hidden}
|
||||
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
|
||||
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
|
||||
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
|
||||
-webkit-font-smoothing:antialiased}
|
||||
.app{display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||||
|
||||
/* ========== HEADER ========== */
|
||||
header.bar{
|
||||
display:flex;align-items:center;gap:18px;padding:0 22px;
|
||||
min-height:62px;height:auto;
|
||||
background:rgba(255,255,255,.86);backdrop-filter:blur(8px);
|
||||
border-bottom:1px solid var(--line);position:relative;z-index:5;
|
||||
overflow:visible;flex-shrink:0;
|
||||
}
|
||||
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0}
|
||||
.brand .mark{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--indigo),#7C8CF5 60%,var(--coral));
|
||||
display:grid;place-items:center;color:#fff;font-family:var(--serif);font-weight:700;font-size:18px;box-shadow:var(--shadow-sm)}
|
||||
.brand .t1{font-family:var(--serif);font-weight:700;font-size:20px;line-height:1.1;letter-spacing:.2px}
|
||||
.brand .t2{font-size:10px;color:var(--mute);letter-spacing:.12em;text-transform:uppercase;line-height:1.2}
|
||||
.search{display:flex;align-items:center;gap:8px;margin-left:8px;flex:1;max-width:640px}
|
||||
.field{display:flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--line);
|
||||
border-radius:10px;padding:7px 11px;box-shadow:var(--shadow-sm);flex:1}
|
||||
.field .k{font-family:var(--mono);font-size:11px;color:var(--mute)}
|
||||
.field input{border:0;outline:0;font-family:var(--mono);font-size:13px;color:var(--ink);width:100%;background:transparent}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:6px 11px;border-radius:999px;white-space:nowrap}
|
||||
.chip.agent{background:var(--t-mint);color:#0E7E72;border:1px solid #B6EBE3}
|
||||
.chip.agent::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 3px rgba(46,196,182,.18);animation:pulse 1.8s infinite}
|
||||
.btn-login{font-size:12px;font-weight:600;color:#fff;background:var(--indigo);border:0;border-radius:10px;padding:8px 15px;cursor:pointer;box-shadow:var(--shadow-sm)}
|
||||
.btn-hot{font-size:12px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--coral),#FF8A7A);text-decoration:none;border:0;border-radius:10px;padding:8px 14px;cursor:pointer;box-shadow:var(--shadow-sm);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
|
||||
.btn-hot:hover{filter:brightness(1.05)}
|
||||
@keyframes pulse{50%{opacity:.4}}
|
||||
|
||||
/* ========== MAIN GRID ========== */
|
||||
main.grid{
|
||||
display:grid;grid-template-columns:262px 1fr 332px;
|
||||
gap:16px;padding:16px;flex:1;min-height:0;overflow:hidden;
|
||||
}
|
||||
aside.left,aside.right{min-height:0;overflow:auto;display:flex;flex-direction:column;gap:12px;padding-right:2px}
|
||||
section.center{min-height:0;display:flex;flex-direction:column;gap:14px;overflow:hidden}
|
||||
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
|
||||
.panel .ph{padding:14px 16px 6px}
|
||||
.panel .ph h3{margin:0;font-size:13px;font-weight:700;letter-spacing:.02em}
|
||||
.panel .ph .sub{font-size:11px;color:var(--mute);margin-top:2px}
|
||||
.eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
|
||||
|
||||
/* ========== SCENARIO CARDS ========== */
|
||||
.scn{display:flex;gap:11px;align-items:flex-start;padding:11px 12px;border:1px solid var(--line);border-radius:12px;
|
||||
background:var(--card);cursor:pointer;transition:.15s;position:relative;box-shadow:var(--shadow-sm)}
|
||||
.scn:hover{border-color:#C7D2FE;transform:translateY(-1px)}
|
||||
.scn.active{border-color:var(--indigo);background:linear-gradient(180deg,#F5F8FF,#fff)}
|
||||
.scn.active::after{content:"";position:absolute;top:10px;right:10px;width:7px;height:7px;border-radius:50%;background:var(--coral)}
|
||||
.scn.running{pointer-events:none;opacity:.7}
|
||||
.scn .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;font-size:15px}
|
||||
.scn .t{min-width:0}
|
||||
.scn .t b{font-size:13px;font-weight:600;display:block}
|
||||
.scn .t small{font-size:11px;color:var(--mute);line-height:1.35;display:block;margin-top:1px}
|
||||
.tint-blue .ico{background:var(--t-blue);color:var(--indigo)}
|
||||
.tint-coral .ico{background:var(--t-coral);color:var(--coral)}
|
||||
.tint-mint .ico{background:var(--t-mint);color:#0E7E72}
|
||||
.tint-amber .ico{background:var(--t-amber);color:#B45309}
|
||||
|
||||
/* ========== CENTER HERO / GRAPH ========== */
|
||||
.hero{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
|
||||
.hero .ph{display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.hero .ph .live{font-family:var(--mono);font-size:11px;color:var(--indigo);background:var(--t-blue);padding:4px 9px;border-radius:999px}
|
||||
.graph-wrap{flex:1;min-height:0;position:relative;margin:0 4px 4px;border-radius:10px;
|
||||
background:radial-gradient(420px 320px at 50% 42%,#F2F6FF 0%,rgba(247,249,252,0) 70%);overflow:hidden}
|
||||
svg.graph{width:100%;height:100%;display:block}
|
||||
.g-edge{stroke:#9DB4F0;stroke-opacity:.55;stroke-width:1}
|
||||
.g-node circle{stroke:#fff;stroke-width:2;transition:.2s}
|
||||
.g-node:hover circle{filter:brightness(1.08)}
|
||||
.g-node text{font-family:var(--sans);font-size:10px;font-weight:600;fill:var(--ink);pointer-events:none;text-anchor:middle}
|
||||
.glow{filter:drop-shadow(0 0 6px rgba(242,107,94,.55))}
|
||||
/* hero-overlay: ONLY visible during loading/empty, hidden after graph renders */
|
||||
.hero-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;pointer-events:none;
|
||||
transition:opacity .3s}
|
||||
.hero-overlay.hidden{opacity:0;pointer-events:none}
|
||||
.hero-overlay .hint{font-family:var(--mono);font-size:12px;color:var(--mute);background:rgba(255,255,255,.8);padding:6px 12px;border-radius:999px}
|
||||
.spin{width:26px;height:26px;border-radius:50%;border:3px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
|
||||
@keyframes rot{to{transform:rotate(360deg)}}
|
||||
.center-extra{flex:0 0 auto;max-height:46%;display:flex;flex-direction:column;gap:10px;overflow:auto}
|
||||
.center-extra:empty{display:none}
|
||||
iframe.plot{width:100%;height:420px;border:0;border-radius:10px;background:#fff}
|
||||
.mmd-box{background:#fff;padding:8px;border-radius:10px;overflow:auto;max-height:420px}
|
||||
|
||||
/* Graph legend bar */
|
||||
.graph-legend{display:flex;align-items:center;gap:14px;padding:6px 16px 10px;flex-wrap:wrap;flex-shrink:0}
|
||||
.graph-legend .leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--ink2)}
|
||||
.graph-legend .leg-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.graph-legend .leg-note{font-size:11px;color:var(--mute);margin-left:auto}
|
||||
|
||||
/* ========== RIGHT COLUMN ========== */
|
||||
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:6px 14px 14px}
|
||||
.tile{border:1px solid var(--line);border-radius:12px;padding:12px;background:#FCFDFE;box-shadow:var(--shadow-sm)}
|
||||
.tile .lab{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink2);font-weight:500}
|
||||
.tile .lab .dot{width:8px;height:8px;border-radius:50%}
|
||||
.tile .val{font-family:var(--mono);font-weight:700;font-size:24px;line-height:1.1;margin-top:6px;color:var(--ink)}
|
||||
.tile .of{font-size:11px;color:var(--mute);margin-top:3px}
|
||||
.d-indigo{background:var(--indigo)} .d-mint{background:var(--mint)} .d-coral{background:var(--coral)} .d-amber{background:#F59E0B}
|
||||
|
||||
.findings{padding:4px 14px 14px;display:flex;flex-direction:column;gap:9px;max-height:330px;overflow:auto}
|
||||
.find{border-left:3px solid var(--indigo);padding:8px 11px;background:#F8FAFF;border-radius:0 9px 9px 0}
|
||||
.find.critical{border-color:var(--coral);background:#FFF5F3}
|
||||
.find.warning{border-color:#F59E0B;background:#FFFBEB}
|
||||
.find.success{border-color:var(--mint);background:#F0FBF9}
|
||||
.find .ft{font-size:11px;font-weight:700;color:var(--ink2);display:flex;justify-content:space-between;gap:8px}
|
||||
.find .fb{font-size:12px;color:var(--ink);margin-top:3px;line-height:1.45}
|
||||
.find .fs{font-size:11px;color:var(--mute);margin-top:3px}
|
||||
.bar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;margin-top:6px}
|
||||
.bar > i{display:block;height:100%;background:linear-gradient(90deg,var(--indigo),#7C8CF5)}
|
||||
.empty{font-size:12px;color:var(--mute);padding:18px;text-align:center}
|
||||
.loading-brief{font-size:12px;color:var(--mute);padding:18px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.loading-brief .spin{width:20px;height:20px;border-width:2px}
|
||||
|
||||
/* CTA button to view full result */
|
||||
.view-result-btn{display:block;margin:8px 14px 14px;padding:10px 18px;border:0;border-radius:12px;
|
||||
background:linear-gradient(135deg,var(--indigo),#7C8CF5);color:#fff;font-family:var(--sans);
|
||||
font-size:13px;font-weight:600;cursor:pointer;text-align:center;box-shadow:var(--shadow);
|
||||
transition:.15s}
|
||||
.view-result-btn:hover{transform:translateY(-1px);box-shadow:0 4px 14px rgba(79,107,237,.25)}
|
||||
|
||||
/* ========== COMMAND DRAWER ========== */
|
||||
footer.drawer{display:flex;align-items:center;gap:14px;padding:0 18px;background:var(--drawer);color:#CBD5E1;font-family:var(--mono);font-size:12px;
|
||||
flex-shrink:0;min-height:52px;height:52px}
|
||||
.dots{display:flex;gap:6px}
|
||||
.dots i{width:11px;height:11px;border-radius:50%;display:block}
|
||||
.drawer .prompt{color:var(--mint)}
|
||||
.drawer .cmd{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#E2E8F0}
|
||||
.drawer .st{color:var(--mute);font-size:11px;white-space:nowrap}
|
||||
.drawer .st.running{color:var(--indigo);animation:pulse 1s infinite}
|
||||
|
||||
/* ========== SCROLLBARS ========== */
|
||||
aside::-webkit-scrollbar,.findings::-webkit-scrollbar,.center-extra::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-thumb{background:#D7DEEA;border-radius:8px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* ========== RESPONSIVE ========== */
|
||||
@media (max-width:1180px){
|
||||
main.grid{grid-template-columns:230px 1fr;grid-template-rows:auto auto}
|
||||
aside.right{grid-column:1 / span 2;flex-direction:row;flex-wrap:wrap}
|
||||
aside.right .panel{flex:1;min-width:280px}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
main.grid{grid-template-columns:1fr;overflow:auto}
|
||||
aside.left,aside.right{grid-column:auto}
|
||||
.search{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- ============ TOP BAR ============ -->
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<div class="mark">G</div>
|
||||
<div>
|
||||
<div class="t1">GitLink Research Atlas</div>
|
||||
<div class="t2">科研代码图谱智能体</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search">
|
||||
<label class="field"><span class="k">repo</span>
|
||||
<input id="owner" value="mindspore-Ecosystem" placeholder="owner">
|
||||
<span class="k" style="color:#CBD5E1">/</span>
|
||||
<input id="repo" value="mindspore" placeholder="repo" style="max-width:150px">
|
||||
</label>
|
||||
<label class="field"><span class="k">keyword</span>
|
||||
<input id="keyword" value="deep learning,机器学习" placeholder="S2 用,逗号分隔">
|
||||
</label>
|
||||
</div>
|
||||
<a class="btn-hot" href="hotspot.html">🔥 热点追踪</a>
|
||||
<span class="chip agent">AI Agent online</span>
|
||||
<button class="btn-login" id="loginBtn">Login</button>
|
||||
</header>
|
||||
|
||||
<!-- ============ MAIN ============ -->
|
||||
<main class="grid">
|
||||
|
||||
<!-- LEFT: scenarios -->
|
||||
<aside class="left">
|
||||
<div class="panel">
|
||||
<div class="ph"><div class="eyebrow">Research Scenarios</div>
|
||||
<h3>科研场景</h3><div class="sub">点选任一场景,服务器终端即运行 gitlink-cli + Python 算法</div>
|
||||
</div>
|
||||
<div id="scenarios" style="padding:8px 12px 14px;display:flex;flex-direction:column;gap:9px"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER: graph / viz -->
|
||||
<section class="center">
|
||||
<div class="panel hero">
|
||||
<div class="ph">
|
||||
<div><div class="eyebrow">Knowledge Graph</div><h3 id="heroTitle">科研知识图谱</h3></div>
|
||||
<span class="live" id="heroLive" style="display:none">● live build</span>
|
||||
</div>
|
||||
<div class="graph-wrap">
|
||||
<svg class="graph" id="graph" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="hero-overlay hidden" id="heroOverlay">
|
||||
<div class="hint">选择左侧科研场景以启动分析</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-legend" id="graphLegend"></div>
|
||||
<div class="center-extra" id="centerExtra"></div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: metrics + report -->
|
||||
<aside class="right">
|
||||
<div class="panel">
|
||||
<div class="ph"><div class="eyebrow">Live Metrics</div><h3>科研指标</h3></div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
</div>
|
||||
<div class="panel" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
||||
<div class="ph"><div class="eyebrow">AI Research Brief</div><h3 id="briefTitle">研究简报</h3></div>
|
||||
<div class="findings" id="findings"><div class="empty">运行场景后,这里展示创新点 / 风险预警 / 协作推荐等结构化结论</div></div>
|
||||
<button class="view-result-btn" id="viewResultBtn" style="display:none" target="_blank">查看完整结果 →</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ============ DRAWER ============ -->
|
||||
<footer class="drawer">
|
||||
<div class="dots"><i style="background:#FF5F57"></i><i style="background:#FEBC2E"></i><i style="background:#28C840"></i></div>
|
||||
<span class="prompt">atlas ❯</span>
|
||||
<span class="cmd" id="cmd">gitlink-cli research --repo mindspore-Ecosystem/mindspore --agent atlas</span>
|
||||
<span class="st" id="cmdStatus">ready</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({startOnLoad:false, theme:"base", securityLevel:"loose",
|
||||
themeVariables:{primaryColor:"#E8EEFF",primaryTextColor:"#1E2A44",primaryBorderColor:"#4F6BED",lineColor:"#9DB4F0",fontSize:"12px"}});
|
||||
|
||||
let SCENARIOS=[], ACTIVE=null, LAST_RESULT_KEY=null;
|
||||
|
||||
/* ---------- node color palette ---------- */
|
||||
const NODE_COLOR = {
|
||||
repo:"#4F6BED", scholar:"#3B82F6", team:"#6366F1", topic:"#06B6D4", method:"#6366F1",
|
||||
paper:"#8B5CF6", dataset:"#2EC4B6", model:"#4F6BED", experiment:"#F26B5E",
|
||||
reproduce:"#2EC4B6", license:"#64748B", trend:"#F26B5E", issue:"#F59E0B", pr:"#10B981",
|
||||
default:"#94A3B8"
|
||||
};
|
||||
const NODE_LABEL = {
|
||||
repo:"仓库", scholar:"学者", topic:"主题", paper:"论文", dataset:"数据集",
|
||||
model:"模型", experiment:"实验", reproduce:"复现", license:"许可证",
|
||||
trend:"趋势", issue:"Issue", pr:"PR"
|
||||
};
|
||||
|
||||
/* Default decorative graph — clean, ~12 nodes showing the research world */
|
||||
const DEMO = {
|
||||
nodes:[
|
||||
{id:"repo",type:"repo",label:"GitLink Repo",imp:true},
|
||||
{id:"scholar",type:"scholar",label:"Scholar"},
|
||||
{id:"topic",type:"topic",label:"Topic"},
|
||||
{id:"paper",type:"paper",label:"Paper"},
|
||||
{id:"dataset",type:"dataset",label:"Dataset"},
|
||||
{id:"model",type:"model",label:"Model"},
|
||||
{id:"exp",type:"experiment",label:"Experiment",imp:true},
|
||||
{id:"repr",type:"reproduce",label:"Reproduce"},
|
||||
{id:"lic",type:"license",label:"License"},
|
||||
{id:"trend",type:"trend",label:"Trend"},
|
||||
{id:"issue",type:"issue",label:"Issue Signals"},
|
||||
{id:"pr",type:"pr",label:"Pull Request"}
|
||||
],
|
||||
edges:[["repo","scholar"],["repo","topic"],["repo","paper"],["repo","dataset"],
|
||||
["repo","model"],["repo","exp"],["repo","repr"],["repo","lic"],
|
||||
["scholar","paper"],["topic","trend"],["exp","repr"],["issue","pr"],["pr","repo"],["model","dataset"]]
|
||||
};
|
||||
|
||||
/* ---------- build legend HTML ---------- */
|
||||
function buildLegend(){
|
||||
const el=document.getElementById("graphLegend");
|
||||
const types=new Set();
|
||||
// gather types from current graph data
|
||||
const src=ACTIVE&&ACTIVE._graphData?ACTIVE._graphData:DEMO;
|
||||
(src.nodes||[]).forEach(n=>types.add(n.type||"default"));
|
||||
let h="";
|
||||
for(const t of [...types].sort()){
|
||||
h+=`<span class="leg-item"><span class="leg-dot" style="background:${NODE_COLOR[t]||NODE_COLOR.default}"></span>${NODE_LABEL[t]||t}</span>`;
|
||||
}
|
||||
h+=`<span class="leg-note">示例:科研仓库与学者/主题/数据集的关联 — 选择 S2 生成真实领域图谱</span>`;
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
/* ---------- force-directed layout ---------- */
|
||||
function forceLayout(nodes, edges, W, H, iters){
|
||||
const cx=W/2, cy=H/2;
|
||||
nodes.forEach(n=>{ if(n.x==null){n.x=cx+(Math.random()-.5)*W*.5; n.y=cy+(Math.random()-.5)*H*.5;} });
|
||||
for(let it=0; it<iters; it++){
|
||||
for(const n of nodes){ n._fx=0; n._fy=0; }
|
||||
for(let i=0;i<nodes.length;i++){
|
||||
for(let j=i+1;j<nodes.length;j++){
|
||||
let dx=nodes[i].x-nodes[j].x, dy=nodes[i].y-nodes[j].y;
|
||||
let d2=dx*dx+dy*dy+0.02, d=Math.sqrt(d2), f=2600/d2;
|
||||
let fx=dx/d*f, fy=dy/d*f;
|
||||
nodes[i]._fx+=fx; nodes[i]._fy+=fy; nodes[j]._fx-=fx; nodes[j]._fy-=fy;
|
||||
}
|
||||
}
|
||||
for(const e of edges){
|
||||
const a=nodes.find(n=>n.id===e[0]), b=nodes.find(n=>n.id===e[1]); if(!a||!b) continue;
|
||||
let dx=b.x-a.x, dy=b.y-a.y, d=Math.sqrt(dx*dx+dy*dy)+.02, L=a.imp||b.imp?120:78, k=.04;
|
||||
let f=(d-L)*k, fx=dx/d*f, fy=dy/d*f;
|
||||
a._fx+=fx; a._fy+=fy; b._fx-=fx; b._fy-=fy;
|
||||
}
|
||||
for(const n of nodes){
|
||||
n._fx+=(cx-n.x)*.012; n._fy+=(cy-n.y)*.012;
|
||||
n.x+=Math.max(-14,Math.min(14,n._fx)); n.y+=Math.max(-14,Math.min(14,n._fy));
|
||||
n.x=Math.max(40,Math.min(W-40,n.x)); n.y=Math.max(34,Math.min(H-30,n.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGraph(data){
|
||||
const svg=document.getElementById("graph");
|
||||
const W=svg.clientWidth||760, H=svg.clientHeight||460;
|
||||
svg.setAttribute("viewBox",`0 0 ${W} ${H}`);
|
||||
let nodes=(data.nodes||[]).map(n=>Object.assign({},n));
|
||||
let rawEdges=(data.edges||[]);
|
||||
// normalize edges to [source,target] pairs
|
||||
let edges=rawEdges.map(e=>Array.isArray(e)?e:[e.source||e.from,e.target||e.to]);
|
||||
// cap to ~40 for performance
|
||||
if(nodes.length>40){
|
||||
const deg=new Map(nodes.map(n=>[n.id,0]));
|
||||
edges.forEach(e=>{deg.set(e[0],(deg.get(e[0])||0)+1);deg.set(e[1],(deg.get(e[1])||0)+1);});
|
||||
nodes.sort((a,b)=>(deg.get(b.id)||0)-(deg.get(a.id)||0));
|
||||
const keep=new Set(nodes.slice(0,40).map(n=>n.id));
|
||||
nodes=nodes.filter(n=>keep.has(n.id));
|
||||
edges=edges.filter(e=>keep.has(e[0])&&keep.has(e[1]));
|
||||
}
|
||||
forceLayout(nodes, edges, W, H, nodes.length>20?220:320);
|
||||
let s=`<defs>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>`;
|
||||
for(const e of edges){
|
||||
const a=nodes.find(n=>n.id===e[0]), b=nodes.find(n=>n.id===e[1]); if(!a||!b) continue;
|
||||
s+=`<line class="g-edge" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`;
|
||||
}
|
||||
for(const n of nodes){
|
||||
const col=NODE_COLOR[n.type]||NODE_COLOR.default;
|
||||
const r=n.imp?11:7;
|
||||
const glowAttr=n.imp?` filter="url(#glow)"`:"";
|
||||
s+=`<g class="g-node" transform="translate(${n.x.toFixed(1)},${n.y.toFixed(1)})">
|
||||
<circle r="${r}" fill="${col}"${glowAttr}/><text y="${r+12}">${esc(n.label||n.id).slice(0,16)}</text></g>`;
|
||||
}
|
||||
svg.innerHTML=s;
|
||||
// BUG FIX #1: after graph renders, immediately hide the overlay
|
||||
hideOverlay();
|
||||
buildLegend();
|
||||
}
|
||||
|
||||
/* ---------- overlay helpers ---------- */
|
||||
const overlay=document.getElementById("heroOverlay");
|
||||
function showOverlay(html){
|
||||
overlay.innerHTML=html||'<div class="hint">选择左侧科研场景以启动分析</div>';
|
||||
overlay.classList.remove("hidden");
|
||||
}
|
||||
function showLoading(msg){
|
||||
overlay.innerHTML=`<div class="spin"></div><div class="hint">${esc(msg||"加载中...")}</div>`;
|
||||
overlay.classList.remove("hidden");
|
||||
}
|
||||
function hideOverlay(){
|
||||
overlay.classList.add("hidden");
|
||||
}
|
||||
|
||||
/* ---------- init scenarios ---------- */
|
||||
async function init(){
|
||||
try{
|
||||
const r=await fetch("/api/scenarios",{headers:{"X-Demo-Token":token()}});
|
||||
const j=await r.json(); SCENARIOS=j.scenarios||[];
|
||||
}catch(e){ SCENARIOS=[]; }
|
||||
const tints=["tint-blue","tint-coral","tint-mint","tint-amber","tint-blue","tint-mint"];
|
||||
const icons=["Ⅰ","Ⅱ","Ⅲ","Ⅳ","Ⅴ","Ⅵ"];
|
||||
const box=document.getElementById("scenarios");
|
||||
box.innerHTML=SCENARIOS.map((s,i)=>`
|
||||
<div class="scn ${tints[i%tints.length]}" data-key="${s.key}">
|
||||
<div class="ico">${icons[i]||"•"}</div>
|
||||
<div class="t"><b>${esc(s.label)}</b><small>${esc(s.desc)}<br><span style="color:#A5B4CF">需: ${(s.needs||[]).join(" · ")}</span></small></div>
|
||||
</div>`).join("");
|
||||
box.querySelectorAll(".scn").forEach(el=>el.onclick=()=>run(el.dataset.key, el));
|
||||
|
||||
// Draw default graph
|
||||
drawGraph(DEMO);
|
||||
setMetrics(defaultMetrics());
|
||||
buildLegend();
|
||||
|
||||
// Login button
|
||||
document.getElementById("loginBtn").onclick=()=>{
|
||||
const t=prompt("粘贴演示 token(留空表示无鉴权):", localStorage.getItem("demo_token")||"");
|
||||
if(t!=null){ localStorage.setItem("demo_token",t.trim()); alert(t.trim()?"已保存":"已清除"); }
|
||||
};
|
||||
|
||||
// View result button
|
||||
document.getElementById("viewResultBtn").onclick=()=>{
|
||||
if(LAST_RESULT_KEY) window.open("result.html?scenario="+LAST_RESULT_KEY,"_blank");
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- run scenario ---------- */
|
||||
async function run(key, el){
|
||||
const s=SCENARIOS.find(x=>x.key===key); if(!s) return;
|
||||
ACTIVE=s;
|
||||
// Mark card active + running
|
||||
document.querySelectorAll(".scn").forEach(e=>{e.classList.remove("active");e.classList.remove("running");});
|
||||
if(el) el.classList.add("active","running");
|
||||
document.getElementById("heroTitle").textContent=s.label;
|
||||
// BUG FIX #3: immediate loading feedback everywhere
|
||||
showLoading("服务器终端执行中:"+s.script+" …");
|
||||
document.getElementById("heroLive").style.display="none";
|
||||
document.getElementById("cmd").textContent=cmdText(s);
|
||||
document.getElementById("cmdStatus").textContent="running…";
|
||||
document.getElementById("cmdStatus").className="st running";
|
||||
document.getElementById("centerExtra").innerHTML="";
|
||||
document.getElementById("viewResultBtn").style.display="none";
|
||||
LAST_RESULT_KEY=null;
|
||||
|
||||
// BUG FIX #3: set brief + metrics to loading state
|
||||
setFindings('<div class="loading-brief"><div class="spin"></div><div>正在分析…</div></div>');
|
||||
setMetrics(tile("indigo","status","…","计算中")+tile("mint","status","…","")+tile("coral","status","…","")+tile("amber","status","…",""));
|
||||
|
||||
const body={scenario:key, owner:val("owner"), repo:val("repo"), keyword:val("keyword")};
|
||||
try{
|
||||
const r=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json","X-Demo-Token":token()},body:JSON.stringify(body)});
|
||||
const j=await r.json();
|
||||
// Clear running state
|
||||
document.querySelectorAll(".scn").forEach(e=>e.classList.remove("running"));
|
||||
document.getElementById("cmdStatus").textContent=(j.ok?"✓ done":"✗ failed")+" · "+(j.duration||"");
|
||||
document.getElementById("cmdStatus").className="st";
|
||||
if(!j.ok){
|
||||
document.getElementById("heroOverlay").innerHTML='<div class="hint" style="color:#F26B5E">运行失败:'+esc(j.error||"未知错误")+'</div>';
|
||||
document.getElementById("heroOverlay").classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
renderResult(s, j);
|
||||
}catch(e){
|
||||
document.querySelectorAll(".scn").forEach(e=>e.classList.remove("running"));
|
||||
document.getElementById("cmdStatus").textContent="✗ 请求失败";
|
||||
document.getElementById("cmdStatus").className="st";
|
||||
document.getElementById("heroOverlay").innerHTML='<div class="hint" style="color:#F26B5E">请求失败:'+esc(e)+'</div>';
|
||||
document.getElementById("heroOverlay").classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function cmdText(s){
|
||||
const o=val("owner"), r=val("repo"), k=val("keyword");
|
||||
if(s.key==="s2") return `gitlink-cli research --graph -k "${k}"`;
|
||||
return `gitlink-cli research ${s.script.replace(".py","")} --repo ${o}/${r}`;
|
||||
}
|
||||
function val(id){return document.getElementById(id).value.trim();}
|
||||
function token(){return localStorage.getItem("demo_token")||"";}
|
||||
|
||||
/* ---------- render result ---------- */
|
||||
function parseJson(j){
|
||||
if(!j.artifacts||!j.artifacts.json) return null;
|
||||
try{return JSON.parse(j.artifacts.json);}catch(e){return null;}
|
||||
}
|
||||
|
||||
function renderResult(s, j){
|
||||
const obj=parseJson(j);
|
||||
const extra=document.getElementById("centerExtra");
|
||||
|
||||
// S2: real knowledge graph
|
||||
if(obj && obj.nodes && obj.edges){
|
||||
const graphData={nodes:obj.nodes.map(n=>({id:n.id,type:n.type||"default",label:n.label||n.id,imp:false})), edges:obj.edges.map(e=>[e.source||e.from,e.target||e.to])};
|
||||
drawGraph(graphData);
|
||||
ACTIVE._graphData=graphData;
|
||||
document.getElementById("heroLive").style.display="";
|
||||
document.getElementById("heroLive").textContent="● live — "+(obj.nodes.length)+" nodes, "+(obj.edges.length)+" edges";
|
||||
}
|
||||
// S6: plotly iframe
|
||||
else if(j.artifacts && j.artifacts.html){
|
||||
const blob=URL.createObjectURL(new Blob([j.artifacts.html],{type:"text/html"}));
|
||||
extra.innerHTML='<div class="panel" style="padding:10px"><div class="eyebrow" style="padding:4px 6px">S6 Visualization · plotly</div>'+
|
||||
'<iframe class="plot" src="'+blob+'"></iframe></div>';
|
||||
// restore demo graph if no real graph
|
||||
drawGraph(DEMO);
|
||||
ACTIVE._graphData=null;
|
||||
}
|
||||
// S1/S4: mermaid
|
||||
else if(j.artifacts && j.artifacts.mermaid){
|
||||
const id="mmd_"+Date.now();
|
||||
extra.innerHTML='<div class="panel" style="padding:10px"><div class="eyebrow" style="padding:4px 6px">关系图 · Mermaid</div><div class="mmd-box"><div class="mermaid" id="'+id+'">'+j.artifacts.mermaid.replace(/```mermaid|```/g,"")+'</div></div></div>';
|
||||
setTimeout(()=>mermaid.run({nodes:["#"+id]}).catch(()=>{}),30);
|
||||
drawGraph(DEMO);
|
||||
ACTIVE._graphData=null;
|
||||
}
|
||||
// S3/S5 or others: keep demo graph, hide overlay
|
||||
else{
|
||||
drawGraph(DEMO);
|
||||
ACTIVE._graphData=null;
|
||||
}
|
||||
|
||||
// Metrics + findings
|
||||
if(obj){
|
||||
setMetrics(metricsFor(s.key, obj));
|
||||
setFindings(findingsFor(s.key, obj));
|
||||
} else {
|
||||
setMetrics(defaultMetrics());
|
||||
setFindings('<div class="empty">'+(j.stdout?esc(j.stdout.slice(-400)):"(无可解析 JSON 产物)")+'</div>');
|
||||
}
|
||||
|
||||
// Show "view full result" CTA
|
||||
LAST_RESULT_KEY=s.key;
|
||||
const btn=document.getElementById("viewResultBtn");
|
||||
btn.style.display="block";
|
||||
btn.textContent="查看完整结果 → "+s.label;
|
||||
}
|
||||
|
||||
/* ---------- metrics per scenario ---------- */
|
||||
function tile(c,label,val,of){return `<div class="tile"><div class="lab"><span class="dot d-${c}"></span>${label}</div><div class="val">${val}</div><div class="of">${of||""}</div></div>`;}
|
||||
function defaultMetrics(){
|
||||
return tile("indigo","commits","1,248","+18% this month")+
|
||||
tile("mint","PR merge","72.6%","healthy collab")+
|
||||
tile("indigo","contributors","36","8 core scholars")+
|
||||
tile("coral","compliance","88","示例数据 · 待运行");
|
||||
}
|
||||
function metricsFor(key,o){
|
||||
const M=o.meta||{};
|
||||
switch(key){
|
||||
case "s1": return tile("indigo","commits",M.commit_count||0,"采样提交")+tile("mint","merged PR",M.merged_pr_count||0,"已合并")+tile("amber","docs",M.doc_count||0,"文档文件")+tile("coral","创新点",(o.innovation_points||[]).length,"高影响合并");
|
||||
case "s2": return tile("indigo","repos",M.repo_count||(o.nodes||[]).length,"仓库节点")+tile("coral","scholars",M.scholar_count||0,"学者")+tile("mint","topics",M.topic_count||0,"主题方向")+tile("amber","edges",M.edge_count||(o.edges||[]).length,"关系边");
|
||||
case "s3": return tile("mint","repro",(o.repro_score||0)+"/10","复现性")+tile("indigo","compliance",(o.compliance_score||0)+"/10","合规")+tile("coral","risks",(o.risks||o.risk_items||[]).length,"风险项")+tile("amber","license",esc(o.license?(typeof o.license==="string"?o.license:(o.license.id||o.license.name||o.license.spdx_id||"—")):(o.license_id||"—")),"识别许可证");
|
||||
case "s4": return tile("indigo","gap topics",(o.gap_topics||[]).length,"缺口主题")+tile("mint","candidates",(o.candidates||[]).length,"推荐候选")+tile("coral","top score",(o.candidates&&o.candidates[0]?o.candidates[0].score:0),"最高匹配")+tile("amber","pool",o.meta&&o.meta.pool_size||0,"候选池");
|
||||
case "s5": {const tw=(o.week_stats&&o.week_stats.this_week)||{}; return tile("indigo","commits/周",tw.commits||0,"本周")+tile("coral","risks",(o.risk_warnings||[]).length,"预警")+tile("mint","PR 合并",tw.prs_merged||0,"本周")+tile("amber","trend",(o.trend&&o.trend.activity_level)||"—","活跃度");}
|
||||
case "s6": {const tl=o.timeline||{}; const sm=a=>(a||[]).reduce((x,y)=>x+(y||0),0); return tile("indigo","weeks",(tl.labels||[]).length,"时间线周数")+tile("coral","commits",sm(tl.commits),"累计")+tile("mint","PRs",sm(tl.prs),"累计")+tile("amber","issues",sm(tl.issues),"累计");}
|
||||
}
|
||||
return defaultMetrics();
|
||||
}
|
||||
function setMetrics(html){document.getElementById("metrics").innerHTML=html;}
|
||||
|
||||
/* ---------- findings per scenario ---------- */
|
||||
function find(cls,title,body,sub){return `<div class="find ${cls}"><div class="ft"><span>${title}</span></div><div class="fb">${body}</div>${sub?`<div class="fs">${sub}</div>`:""}</div>`;}
|
||||
function rank(c){return `<div class="bar"><i style="width:${Math.min(100,Math.max(4,c))}%"></i></div>`;}
|
||||
function findingsFor(key,o){
|
||||
let out="";
|
||||
switch(key){
|
||||
case "s1": (o.innovation_points||[]).slice(0,6).forEach(i=>{out+=find("warning","["+i.category+"] 创新点",esc(i.description),esc(i.evidence));}); break;
|
||||
case "s2": (o.topic_heat||[]).slice(0,4).forEach(h=>{out+=find("success","主题热度 · "+h.topic,"覆盖 "+h.count+" 个仓库",rank(h.count*15));});
|
||||
(o.core_scholars||[]).slice(0,3).forEach(s=>{out+=find("",esc(s.login),"关联 "+s.repo_count+" 个仓库");}); break;
|
||||
case "s3": (o.risk_items||o.risks||[]).slice(0,6).forEach(r=>{const lv=(r.level||"").includes("high")||r.severity==="P0"?"critical":"warning"; out+=find(lv,esc((r.level||r.severity||"")+" · "+(r.category||r.name||"")),esc(r.detail||r.evidence||r.name||""),esc(r.file?r.file:""));});
|
||||
out+=find("success","复现性 "+(o.repro_score||0)+"/10 · 合规 "+(o.compliance_score||0)+"/10","许可证: "+esc(o.license?(typeof o.license==="string"?o.license:(o.license.id||"—")):"—")); break;
|
||||
case "s4": (o.candidates||[]).slice(0,5).forEach((c,i)=>{out+=find(i===0?"success":"", "#"+(i+1)+" "+esc(c.login), (c.reasons||[]).join(";")||"—", rank(c.score)+` <span style="float:right">${c.score}分</span>`);}); break;
|
||||
case "s5": (o.risk_warnings||[]).slice(0,6).forEach(w=>{out+=find(w.level==="critical"?"critical":"warning",esc(w.level+" · "+w.type),esc(w.message),esc(w.suggestion||""));}); break;
|
||||
case "s6": out+=find("success","可视化产物","时间线 "+((o.timeline&&o.timeline.labels||[]).length)+" 周 · 语言 "+((o.language_pie&&o.language_pie.length)||0)+" 类","已生成可交互 plotly 图表"); break;
|
||||
}
|
||||
return out||'<div class="empty">(该场景无结构化简报,详见完整结果页)</div>';
|
||||
}
|
||||
function setFindings(html){document.getElementById("findings").innerHTML=html;}
|
||||
|
||||
/* ---------- utilities ---------- */
|
||||
function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||
|
||||
/* ---------- resize handler ---------- */
|
||||
let resizeTimer;
|
||||
window.addEventListener("resize",()=>{
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer=setTimeout(()=>{
|
||||
const src=ACTIVE&&ACTIVE._graphData?ACTIVE._graphData:DEMO;
|
||||
drawGraph(src);
|
||||
},200);
|
||||
});
|
||||
|
||||
/* go */
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,504 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink Research Atlas · 结果详情</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Serif+SC:wght@600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#F7F9FC; --card:#FFFFFF; --ink:#1E2A44; --ink2:#475569; --mute:#94A3B8;
|
||||
--line:#E6EBF2; --indigo:#4F6BED; --coral:#F26B5E; --mint:#2EC4B6;
|
||||
--t-blue:#E8EEFF; --t-coral:#FFEDEA; --t-mint:#E6F7F4; --t-amber:#FEF3C7;
|
||||
--serif:"Cormorant Garamond","Noto Serif SC",Georgia,serif;
|
||||
--sans:"Inter","Noto Sans SC",-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--shadow:0 1px 2px rgba(30,42,68,.04),0 6px 18px rgba(30,42,68,.06);
|
||||
--shadow-sm:0 1px 2px rgba(30,42,68,.06);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:auto;min-height:100%;margin:0}
|
||||
body{font-family:var(--sans);color:var(--ink);background:var(--bg);
|
||||
background-image:radial-gradient(1200px 480px at 80% -8%,#EAF0FF 0%,rgba(234,240,255,0) 60%),
|
||||
radial-gradient(900px 420px at 0% 0%,#FFF1EE 0%,rgba(255,241,238,0) 55%);
|
||||
-webkit-font-smoothing:antialiased}
|
||||
|
||||
/* ========== PRINT ========== */
|
||||
@media print{
|
||||
body{background:#fff!important}
|
||||
.no-print{display:none!important}
|
||||
.page-wrap{max-width:100%!important;box-shadow:none!important}
|
||||
}
|
||||
|
||||
/* ========== PAGE WRAPPER ========== */
|
||||
.page-wrap{max-width:1120px;margin:0 auto;padding:32px 40px 60px}
|
||||
@media(max-width:760px){.page-wrap{padding:20px 16px 40px}}
|
||||
|
||||
/* ========== HEADER ========== */
|
||||
.res-header{margin-bottom:32px}
|
||||
.res-header .back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;
|
||||
color:var(--indigo);text-decoration:none;margin-bottom:16px;padding:6px 14px;border-radius:10px;
|
||||
background:var(--t-blue);transition:.15s}
|
||||
.res-header .back:hover{background:#D6DEFF}
|
||||
.res-header h1{font-family:var(--serif);font-weight:700;font-size:34px;line-height:1.2;letter-spacing:.3px;color:var(--ink)}
|
||||
.res-header .sub{font-size:15px;color:var(--ink2);margin-top:6px;line-height:1.5}
|
||||
.res-header .meta{display:flex;flex-wrap:wrap;gap:16px;margin-top:14px;align-items:center}
|
||||
.res-header .meta .chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;
|
||||
padding:5px 12px;border-radius:999px;background:var(--card);border:1px solid var(--line);color:var(--ink2);box-shadow:var(--shadow-sm)}
|
||||
.res-header .meta .chip b{font-family:var(--mono);font-weight:700;color:var(--ink)}
|
||||
.res-header .cmd-drawer{margin-top:14px;background:var(--ink);color:#CBD5E1;font-family:var(--mono);
|
||||
font-size:12px;padding:10px 16px;border-radius:10px;display:flex;align-items:center;gap:10px;overflow:hidden}
|
||||
.res-header .cmd-drawer .dots{display:flex;gap:6px;flex-shrink:0}
|
||||
.res-header .cmd-drawer .dots i{width:11px;height:11px;border-radius:50%;display:block}
|
||||
.res-header .cmd-drawer .cmd{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#E2E8F0}
|
||||
|
||||
/* ========== SECTION TITLES ========== */
|
||||
.section{margin-top:36px}
|
||||
.section-title{font-family:var(--serif);font-weight:700;font-size:22px;color:var(--ink);margin-bottom:16px;
|
||||
padding-bottom:8px;border-bottom:2px solid var(--line)}
|
||||
.section-title .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
|
||||
color:var(--mute);display:block;margin-bottom:4px}
|
||||
|
||||
/* ========== METRICS ROW ========== */
|
||||
.metrics-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:8px}
|
||||
@media(max-width:760px){.metrics-row{grid-template-columns:repeat(2,1fr)}}
|
||||
.metric-card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;
|
||||
box-shadow:var(--shadow);text-align:center}
|
||||
.metric-card .label{font-size:12px;color:var(--ink2);font-weight:500;margin-bottom:8px}
|
||||
.metric-card .value{font-family:var(--mono);font-weight:700;font-size:32px;color:var(--ink);line-height:1}
|
||||
.metric-card .note{font-size:11px;color:var(--mute);margin-top:6px}
|
||||
|
||||
/* ========== KNOWLEDGE GRAPH ========== */
|
||||
.graph-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
|
||||
padding:20px;overflow:hidden}
|
||||
.graph-svg-wrap{width:100%;height:500px;position:relative;background:radial-gradient(500px 380px at 50% 45%,#F2F6FF 0%,rgba(255,255,255,0) 70%);
|
||||
border-radius:10px;overflow:hidden}
|
||||
.graph-svg-wrap svg{width:100%;height:100%;display:block}
|
||||
.g-edge{stroke:#9DB4F0;stroke-opacity:.55;stroke-width:1}
|
||||
.g-node circle{stroke:#fff;stroke-width:2;transition:.2s}
|
||||
.g-node:hover circle{filter:brightness(1.08)}
|
||||
.g-node text{font-family:var(--sans);font-size:11px;font-weight:600;fill:var(--ink);pointer-events:none;text-anchor:middle}
|
||||
.graph-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
|
||||
.graph-legend .leg-item{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--ink2)}
|
||||
.graph-legend .leg-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.graph-legend .leg-note{font-size:12px;color:var(--mute);margin-left:auto}
|
||||
|
||||
/* ========== VIZ IFRAME (S6) ========== */
|
||||
.viz-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
|
||||
padding:12px;overflow:hidden}
|
||||
.viz-container iframe{width:100%;height:520px;border:0;border-radius:10px;background:#fff}
|
||||
|
||||
/* ========== MERMAID ========== */
|
||||
.mmd-container{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
|
||||
padding:16px;overflow:auto;max-height:600px}
|
||||
|
||||
/* ========== RADAR / SCORE (S3) ========== */
|
||||
.score-display{display:flex;align-items:center;justify-content:center;gap:40px;padding:30px;
|
||||
background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);flex-wrap:wrap}
|
||||
.score-circle{width:140px;height:140px;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
position:relative;border:6px solid}
|
||||
.score-circle .val{font-family:var(--mono);font-weight:700;font-size:40px;line-height:1}
|
||||
.score-circle .label{font-size:13px;color:var(--ink2);margin-top:2px}
|
||||
.score-circle.repro{border-color:var(--mint);color:var(--mint);background:var(--t-mint)}
|
||||
.score-circle.compliance{border-color:var(--indigo);color:var(--indigo);background:var(--t-blue)}
|
||||
|
||||
/* ========== WEEKLY COMPARISON (S5) ========== */
|
||||
.weekly-bars{display:flex;flex-direction:column;gap:16px;padding:20px;
|
||||
background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
|
||||
.week-row{display:flex;align-items:center;gap:14px}
|
||||
.week-row .label{width:70px;font-size:13px;font-weight:600;color:var(--ink2);flex-shrink:0}
|
||||
.week-row .bar-bg{flex:1;height:28px;background:#F1F5F9;border-radius:8px;overflow:hidden;position:relative}
|
||||
.week-row .bar-fill{height:100%;border-radius:8px;transition:width .6s ease}
|
||||
.week-row .bar-fill.this-week{background:linear-gradient(90deg,var(--indigo),#7C8CF5)}
|
||||
.week-row .bar-fill.last-week{background:var(--line)}
|
||||
.week-row .bar-val{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-family:var(--mono);font-size:12px;font-weight:600;color:var(--ink)}
|
||||
.risk-list{margin-top:20px;display:flex;flex-direction:column;gap:10px}
|
||||
.risk-item{padding:10px 14px;border-radius:10px;border-left:4px solid}
|
||||
.risk-item.critical{border-color:var(--coral);background:var(--t-coral)}
|
||||
.risk-item.warning{border-color:#F59E0B;background:var(--t-amber)}
|
||||
.risk-item .type{font-size:11px;font-family:var(--mono);color:var(--mute);text-transform:uppercase}
|
||||
.risk-item .msg{font-size:13px;color:var(--ink);margin-top:4px}
|
||||
.risk-item .suggestion{font-size:12px;color:var(--ink2);margin-top:4px;font-style:italic}
|
||||
|
||||
/* ========== REPORT (markdown) ========== */
|
||||
.report-content{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
|
||||
padding:28px 32px;line-height:1.75;font-size:14px;color:var(--ink)}
|
||||
.report-content h1{font-family:var(--serif);font-weight:700;font-size:26px;margin:24px 0 12px;padding-bottom:8px;border-bottom:2px solid var(--line)}
|
||||
.report-content h1:first-child{margin-top:0}
|
||||
.report-content h2{font-family:var(--serif);font-weight:700;font-size:20px;margin:20px 0 10px;color:var(--ink)}
|
||||
.report-content h3{font-weight:700;font-size:16px;margin:16px 0 8px;color:var(--ink2)}
|
||||
.report-content p{margin:10px 0}
|
||||
.report-content ul,.report-content ol{margin:8px 0;padding-left:24px}
|
||||
.report-content li{margin:4px 0}
|
||||
.report-content strong{font-weight:700;color:var(--ink)}
|
||||
.report-content code{font-family:var(--mono);font-size:12.5px;background:var(--t-blue);padding:2px 6px;border-radius:4px;color:var(--indigo)}
|
||||
.report-content table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
|
||||
.report-content th{background:var(--t-blue);font-weight:600;text-align:left;padding:8px 12px;border:1px solid var(--line)}
|
||||
.report-content td{padding:8px 12px;border:1px solid var(--line)}
|
||||
|
||||
/* ========== STRUCTURED CARDS ========== */
|
||||
.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px;margin-top:20px}
|
||||
.conclusion-card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);
|
||||
padding:16px 18px;border-top:4px solid}
|
||||
.conclusion-card.innovation{border-top-color:var(--indigo)}
|
||||
.conclusion-card.risk{border-top-color:var(--coral)}
|
||||
.conclusion-card.collab{border-top-color:var(--mint)}
|
||||
.conclusion-card .card-title{font-weight:700;font-size:14px;color:var(--ink);margin-bottom:8px}
|
||||
.conclusion-card .card-body{font-size:13px;color:var(--ink2);line-height:1.55}
|
||||
.conclusion-card .card-body p{margin:4px 0}
|
||||
|
||||
/* ========== LOADING STATE ========== */
|
||||
.loading-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;color:var(--mute);gap:16px}
|
||||
.spin-lg{width:40px;height:40px;border-radius:50%;border:4px solid var(--t-blue);border-top-color:var(--indigo);animation:rot .8s linear infinite}
|
||||
@keyframes rot{to{transform:rotate(360deg)}}
|
||||
|
||||
/* ========== ERROR ========== */
|
||||
.error-state{padding:60px 20px;text-align:center;color:var(--coral)}
|
||||
.error-state h2{font-family:var(--serif);font-size:24px;margin-bottom:12px}
|
||||
.error-state p{font-size:14px;color:var(--ink2);max-width:500px;margin:0 auto}
|
||||
|
||||
/* empty */
|
||||
.empty-state{padding:60px 20px;text-align:center;color:var(--mute)}
|
||||
.empty-state p{font-size:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-wrap" id="app">
|
||||
<div class="loading-state"><div class="spin-lg"></div><div>正在加载结果…</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({startOnLoad:false, theme:"base", securityLevel:"loose",
|
||||
themeVariables:{primaryColor:"#E8EEFF",primaryTextColor:"#1E2A44",primaryBorderColor:"#4F6BED",lineColor:"#9DB4F0",fontSize:"13px"}});
|
||||
|
||||
const NODE_COLOR={repo:"#4F6BED",scholar:"#3B82F6",team:"#6366F1",topic:"#06B6D4",method:"#6366F1",
|
||||
paper:"#8B5CF6",dataset:"#2EC4B6",model:"#4F6BED",experiment:"#F26B5E",
|
||||
reproduce:"#2EC4B6",license:"#64748B",trend:"#F26B5E",issue:"#F59E0B",pr:"#10B981",default:"#94A3B8"};
|
||||
const NODE_LABEL={repo:"仓库",scholar:"学者",topic:"主题",paper:"论文",dataset:"数据集",
|
||||
model:"模型",experiment:"实验",reproduce:"复现",license:"许可证",trend:"趋势",issue:"Issue",pr:"PR"};
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||
function token(){return localStorage.getItem("demo_token")||"";}
|
||||
function licOf(o){const l=o.license;if(!l)return o.license_id||"—";return typeof l==="string"?l:(l.id||l.name||l.spdx_id||l.identifier||"—");}
|
||||
|
||||
/* ========== Force layout (same as index) ========== */
|
||||
function forceLayout(nodes,edges,W,H,iters){
|
||||
const cx=W/2,cy=H/2;
|
||||
nodes.forEach(n=>{if(n.x==null){n.x=cx+(Math.random()-.5)*W*.5;n.y=cy+(Math.random()-.5)*H*.5;}});
|
||||
for(let it=0;it<iters;it++){
|
||||
for(const n of nodes){n._fx=0;n._fy=0;}
|
||||
for(let i=0;i<nodes.length;i++){for(let j=i+1;j<nodes.length;j++){
|
||||
let dx=nodes[i].x-nodes[j].x,dy=nodes[i].y-nodes[j].y,d2=dx*dx+dy*dy+.02,d=Math.sqrt(d2),f=2600/d2,fx=dx/d*f,fy=dy/d*f;
|
||||
nodes[i]._fx+=fx;nodes[i]._fy+=fy;nodes[j]._fx-=fx;nodes[j]._fy-=fy;}}
|
||||
for(const e of edges){const a=nodes.find(n=>n.id===e[0]),b=nodes.find(n=>n.id===e[1]);if(!a||!b)continue;
|
||||
let dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)+.02,L=a.imp||b.imp?130:85,k=.04;
|
||||
let f=(d-L)*k,fx=dx/d*f,fy=dy/d*f;a._fx+=fx;a._fy+=fy;b._fx-=fx;b._fy-=fy;}
|
||||
for(const n of nodes){n._fx+=(cx-n.x)*.012;n._fy+=(cy-n.y)*.012;
|
||||
n.x+=Math.max(-14,Math.min(14,n._fx));n.y+=Math.max(-14,Math.min(14,n._fy));
|
||||
n.x=Math.max(45,Math.min(W-45,n.x));n.y=Math.max(38,Math.min(H-34,n.y));}}
|
||||
}
|
||||
|
||||
function drawGraphIn(containerId,data){
|
||||
const svg=document.getElementById(containerId); if(!svg) return;
|
||||
const W=svg.clientWidth||800,H=svg.clientHeight||500;
|
||||
svg.setAttribute("viewBox",`0 0 ${W} ${H}`);
|
||||
let nodes=(data.nodes||[]).map(n=>Object.assign({},n));
|
||||
let edges=(data.edges||[]).map(e=>Array.isArray(e)?e:[e.source||e.from,e.target||e.to]);
|
||||
if(nodes.length>40){
|
||||
const deg=new Map(nodes.map(n=>[n.id,0]));
|
||||
edges.forEach(e=>{deg.set(e[0],(deg.get(e[0])||0)+1);deg.set(e[1],(deg.get(e[1])||0)+1);});
|
||||
nodes.sort((a,b)=>(deg.get(b.id)||0)-(deg.get(a.id)||0));
|
||||
const keep=new Set(nodes.slice(0,40).map(n=>n.id));nodes=nodes.filter(n=>keep.has(n.id));edges=edges.filter(e=>keep.has(e[0])&&keep.has(e[1]));
|
||||
}
|
||||
forceLayout(nodes,edges,W,H,300);
|
||||
let s=`<defs><filter id="glow2"><feGaussianBlur stdDeviation="5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>`;
|
||||
for(const e of edges){const a=nodes.find(n=>n.id===e[0]),b=nodes.find(n=>n.id===e[1]);if(!a||!b)continue;
|
||||
s+=`<line class="g-edge" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`;}
|
||||
for(const n of nodes){const col=NODE_COLOR[n.type]||NODE_COLOR.default,r=n.imp?13:8;
|
||||
s+=`<g class="g-node" transform="translate(${n.x.toFixed(1)},${n.y.toFixed(1)})"><circle r="${r}" fill="${col}"${n.imp?' filter="url(#glow2)"':""}/><text y="${r+13}">${esc(n.label||n.id).slice(0,18)}</text></g>`;}
|
||||
svg.innerHTML=s;
|
||||
}
|
||||
|
||||
function buildGraphLegend(types){
|
||||
let h="";
|
||||
for(const t of [...types].sort()){
|
||||
h+=`<span class="leg-item"><span class="leg-dot" style="background:${NODE_COLOR[t]||NODE_COLOR.default}"></span>${NODE_LABEL[t]||t}</span>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ========== Simple markdown to HTML ========== */
|
||||
function mdToHtml(md){
|
||||
if(!md) return "";
|
||||
let html=esc(md);
|
||||
// code blocks
|
||||
html=html.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre><code>$2</code></pre>');
|
||||
// inline code
|
||||
html=html.replace(/`([^`]+)`/g,'<code>$1</code>');
|
||||
// tables
|
||||
html=html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)*)/gm,function(_,hdr,sep,body){
|
||||
const ths=hdr.split("|").filter(c=>c.trim()).map(c=>`<th>${c.trim()}</th>`).join("");
|
||||
let rows="";
|
||||
body.trim().split("\n").forEach(row=>{
|
||||
const tds=row.split("|").filter(c=>c.trim()).map(c=>`<td>${c.trim()}</td>`).join("");
|
||||
rows+=`<tr>${tds}</tr>`;
|
||||
});
|
||||
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
});
|
||||
// headings
|
||||
html=html.replace(/^### (.+)$/gm,'<h3>$1</h3>');
|
||||
html=html.replace(/^## (.+)$/gm,'<h2>$1</h2>');
|
||||
html=html.replace(/^# (.+)$/gm,'<h1>$1</h1>');
|
||||
// bold
|
||||
html=html.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
|
||||
// unordered lists
|
||||
html=html.replace(/^- (.+)$/gm,'<li>$1</li>');
|
||||
html=html.replace(/(<li>.*<\/li>\n?)+/g,'<ul>$&</ul>');
|
||||
// paragraphs (lines not inside tags)
|
||||
html=html.replace(/^(?!<[huplo])((?!<).+)$/gm,'<p>$1</p>');
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ========== MAIN RENDER ========== */
|
||||
async function loadResult(){
|
||||
const params=new URLSearchParams(window.location.search);
|
||||
const scenario=params.get("scenario");
|
||||
if(!scenario){
|
||||
document.getElementById("app").innerHTML='<div class="error-state"><h2>缺少场景参数</h2><p>请从仪表盘点击场景,或手动打开 <code>result.html?scenario=s1</code></p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try{
|
||||
const r=await fetch("/api/result/"+encodeURIComponent(scenario),{headers:{"X-Demo-Token":token()}});
|
||||
data=await r.json();
|
||||
}catch(e){
|
||||
document.getElementById("app").innerHTML='<div class="error-state"><h2>加载失败</h2><p>'+esc(e.message)+'</p><a class="back" href="index.html">返回仪表盘</a></div>';
|
||||
return;
|
||||
}
|
||||
if(!data.ok){
|
||||
document.getElementById("app").innerHTML='<div class="error-state"><h2>'+esc(data.error||"数据未就绪")+'</h2><p>请先在仪表盘运行该场景以生成结果。</p><a class="back" href="index.html">返回仪表盘</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const art=data.artifacts||{};
|
||||
const obj=art.json?JSON.parse(art.json):null;
|
||||
const owner=params.get("owner")||val_from("owner")||"";
|
||||
const repo=params.get("repo")||val_from("repo")||"";
|
||||
const keyword=params.get("keyword")||val_from("keyword")||"";
|
||||
|
||||
render(data,obj,art,scenario,owner,repo,keyword);
|
||||
}
|
||||
|
||||
function val_from(name){try{return localStorage.getItem("atlas_"+name)||"";}catch(e){return"";}}
|
||||
|
||||
function render(data,obj,art,scenario,owner,repo,keyword){
|
||||
const label=data.label||scenario;
|
||||
const desc=data.desc||"";
|
||||
const now=new Date().toLocaleString("zh-CN");
|
||||
let h="";
|
||||
|
||||
// ---- HEADER ----
|
||||
h+=`<div class="res-header">
|
||||
<a class="back no-print" href="index.html">← 返回仪表盘</a>
|
||||
<h1>${esc(label)}</h1>
|
||||
<div class="sub">${esc(desc)}</div>
|
||||
<div class="meta">`;
|
||||
if(owner||repo) h+=`<span class="chip">仓库 <b>${esc(owner)}${repo?"/"+esc(repo):""}</b></span>`;
|
||||
if(keyword) h+=`<span class="chip">关键词 <b>${esc(keyword)}</b></span>`;
|
||||
h+=`<span class="chip">场景 <b>${esc(scenario)}</b></span>
|
||||
<span class="chip">生成于 ${esc(now)}</span>
|
||||
</div>
|
||||
<div class="cmd-drawer no-print">
|
||||
<div class="dots"><i style="background:#FF5F57"></i><i style="background:#FEBC2E"></i><i style="background:#28C840"></i></div>
|
||||
<span style="color:var(--mint)">atlas ❯</span>
|
||||
<span class="cmd">gitlink-cli research --scenario ${esc(scenario)} ${owner?"--repo "+esc(owner)+"/"+esc(repo):""} ${keyword?'-k "'+esc(keyword)+'"':""}</span>
|
||||
</div></div>`;
|
||||
|
||||
// ---- METRICS ROW ----
|
||||
const metrics=metricsFor(scenario,obj);
|
||||
if(metrics){
|
||||
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Key Metrics</span>关键指标</div>
|
||||
<div class="metrics-row">${metrics}</div></div>`;
|
||||
}
|
||||
|
||||
// ---- MAIN VIZ ----
|
||||
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Visualization</span>可视化</div>`;
|
||||
|
||||
// S2: Knowledge Graph
|
||||
if(scenario==="s2"&&obj&&obj.nodes&&obj.edges){
|
||||
const graphNodes=obj.nodes.map(n=>({id:n.id,type:n.type||"default",label:n.label||n.id,imp:false}));
|
||||
// highlight repo and high-degree nodes
|
||||
const deg=new Map();
|
||||
obj.edges.forEach(e=>{deg.set((e.source||e.from),(deg.get(e.source||e.from)||0)+1);deg.set((e.target||e.to),(deg.get(e.target||e.to)||0)+1);});
|
||||
graphNodes.forEach(n=>{if(n.type==="repo"||deg.get(n.id)>3)n.imp=true;});
|
||||
const graphEdges=obj.edges.map(e=>[e.source||e.from,e.target||e.to]);
|
||||
const types=new Set(graphNodes.map(n=>n.type));
|
||||
const gId="resGraph_"+Date.now();
|
||||
h+=`<div class="graph-container">
|
||||
<div class="graph-svg-wrap"><svg id="${gId}" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="graph-legend">${buildGraphLegend(types)}
|
||||
<span class="leg-note">${esc(label)} — ${(obj.meta||{}).repo_count||graphNodes.length} 个仓库节点, ${(obj.meta||{}).edge_count||graphEdges.length} 条关联边</span>
|
||||
</div></div>`;
|
||||
// defer drawing
|
||||
setTimeout(()=>drawGraphIn(gId,{nodes:graphNodes,edges:graphEdges}),100);
|
||||
}
|
||||
// S6: plotly
|
||||
else if(scenario==="s6"&&art.html){
|
||||
const blob=URL.createObjectURL(new Blob([art.html],{type:"text/html"}));
|
||||
h+=`<div class="viz-container"><iframe src="${blob}"></iframe></div>`;
|
||||
}
|
||||
// S1/S4: mermaid
|
||||
else if((scenario==="s1"||scenario==="s4")&&art.mermaid){
|
||||
const mId="resMmd_"+Date.now();
|
||||
const mmd=art.mermaid.replace(/```mermaid|```/g,"");
|
||||
h+=`<div class="mmd-container"><div class="mermaid" id="${mId}">${esc(mmd)}</div></div>`;
|
||||
setTimeout(()=>{const el=document.getElementById(mId);if(el)el.removeAttribute("data-processed");
|
||||
mermaid.run({nodes:["#"+mId]}).catch(()=>{});},100);
|
||||
}
|
||||
// S3: radar / scores
|
||||
else if(scenario==="s3"&&obj){
|
||||
const rs=obj.repro_score||0;const cs=obj.compliance_score||0;
|
||||
h+=`<div class="score-display">
|
||||
<div class="score-circle repro"><span class="val">${rs}</span><span class="label">复现性 / 10</span></div>
|
||||
<div class="score-circle compliance"><span class="val">${cs}</span><span class="label">合规性 / 10</span></div>
|
||||
</div>`;
|
||||
}
|
||||
// S5: weekly comparison + risks
|
||||
else if(scenario==="s5"&&obj){
|
||||
const ws=obj.week_stats||{};
|
||||
const tw=ws.this_week||{};const lw=ws.last_week||{};
|
||||
const maxC=Math.max(tw.commits||0,lw.commits||0,1);
|
||||
const maxP=Math.max(tw.prs_merged||0,lw.prs_merged||0,1);
|
||||
const maxI=Math.max(tw.issues_closed||0,lw.issues_closed||0,1);
|
||||
h+=`<div class="weekly-bars">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:8px;color:var(--ink)">本周 vs 上周 对比</div>
|
||||
<div class="week-row"><span class="label">Commits</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.commits/maxC*100||0).toFixed(1)}%"><span class="bar-val">${lw.commits||0}</span></div><div class="bar-fill this-week" style="width:${(tw.commits/maxC*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.commits||0}</span></div></div></div>
|
||||
<div class="week-row"><span class="label">PR merged</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.prs_merged/maxP*100||0).toFixed(1)}%"><span class="bar-val">${lw.prs_merged||0}</span></div><div class="bar-fill this-week" style="width:${(tw.prs_merged/maxP*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.prs_merged||0}</span></div></div></div>
|
||||
<div class="week-row"><span class="label">Issues</span><div class="bar-bg"><div class="bar-fill last-week" style="width:${(lw.issues_closed/maxI*100||0).toFixed(1)}%"><span class="bar-val">${lw.issues_closed||0}</span></div><div class="bar-fill this-week" style="width:${(tw.issues_closed/maxI*100||0).toFixed(1)}%;position:relative;margin-top:-28px"><span class="bar-val">${tw.issues_closed||0}</span></div></div></div>
|
||||
</div>`;
|
||||
// risk warnings
|
||||
const risks=obj.risk_warnings||[];
|
||||
if(risks.length){
|
||||
h+=`<div style="margin-top:20px;font-weight:700;font-size:14px;color:var(--ink)">风险预警</div><div class="risk-list">`;
|
||||
risks.forEach(r=>{const cls=r.level==="critical"?"critical":"warning";
|
||||
h+=`<div class="risk-item ${cls}"><div class="type">${esc(r.level||"")} · ${esc(r.type||"")}</div><div class="msg">${esc(r.message||"")}</div>${r.suggestion?`<div class="suggestion">${esc(r.suggestion)}</div>`:""}</div>`;});
|
||||
h+=`</div>`;
|
||||
}
|
||||
}
|
||||
else{
|
||||
h+=`<div class="empty-state"><p>该场景无可视化产物,详见下方报告。</p></div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
|
||||
// ---- REPORT ----
|
||||
if(art.report){
|
||||
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Full Report</span>完整报告</div>
|
||||
<div class="report-content">${mdToHtml(art.report)}</div></div>`;
|
||||
}
|
||||
|
||||
// ---- STRUCTURED CONCLUSION CARDS ----
|
||||
const conclusions=buildConclusionCards(scenario,obj);
|
||||
if(conclusions){
|
||||
h+=`<div class="section"><div class="section-title"><span class="eyebrow">Conclusions</span>结构化结论</div>
|
||||
<div class="cards-grid">${conclusions}</div></div>`;
|
||||
}
|
||||
|
||||
document.getElementById("app").innerHTML=h;
|
||||
}
|
||||
|
||||
/* ========== METRICS ========== */
|
||||
function metricCard(label,val,note){
|
||||
return `<div class="metric-card"><div class="label">${esc(label)}</div><div class="value">${esc(String(val))}</div><div class="note">${esc(note||"")}</div></div>`;
|
||||
}
|
||||
function metricsFor(key,o){
|
||||
if(!o) return "";
|
||||
const M=o.meta||{};
|
||||
switch(key){
|
||||
case "s1": return metricCard("commits",M.commit_count||0,"采样提交")+metricCard("merged PR",M.merged_pr_count||0,"已合并")+metricCard("docs",M.doc_count||0,"文档文件")+metricCard("创新点",(o.innovation_points||[]).length,"高影响合并");
|
||||
case "s2": return metricCard("repos",M.repo_count||(o.nodes||[]).length,"仓库节点")+metricCard("scholars",M.scholar_count||0,"学者")+metricCard("topics",M.topic_count||0,"主题方向")+metricCard("edges",M.edge_count||(o.edges||[]).length,"关系边");
|
||||
case "s3": return metricCard("复现性",(o.repro_score||0)+"/10","reproducibility")+metricCard("合规性",(o.compliance_score||0)+"/10","compliance")+metricCard("风险项",(o.risks||o.risk_items||[]).length,"项")+metricCard("许可证",esc(licOf(o)),"识别");
|
||||
case "s4": return metricCard("缺口主题",(o.gap_topics||[]).length,"gap topics")+metricCard("推荐候选",(o.candidates||[]).length,"candidates")+metricCard("最高匹配",o.candidates&&o.candidates[0]?o.candidates[0].score:0,"top score")+metricCard("候选池",o.meta&&o.meta.pool_size||0,"pool size");
|
||||
case "s5":{const tw=(o.week_stats&&o.week_stats.this_week)||{};return metricCard("本周 commits",tw.commits||0,"")+metricCard("预警",(o.risk_warnings||[]).length,"risks")+metricCard("PR 合并",tw.prs_merged||0,"")+metricCard("活跃度",(o.trend&&o.trend.activity_level)||"—","");}
|
||||
case "s6":{const tl=o.timeline||{};const sm=a=>(a||[]).reduce((x,y)=>x+(y||0),0);return metricCard("周数",(tl.labels||[]).length,"")+metricCard("commits",sm(tl.commits),"")+metricCard("PRs",sm(tl.prs),"")+metricCard("issues",sm(tl.issues),"");}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/* ========== CONCLUSION CARDS ========== */
|
||||
function buildConclusionCards(key,o){
|
||||
if(!o) return "";
|
||||
let cards="";
|
||||
switch(key){
|
||||
case "s1":{
|
||||
const ips=o.innovation_points||[];
|
||||
if(ips.length){
|
||||
cards+=`<div class="conclusion-card innovation"><div class="card-title">💡 创新点 (${ips.length})</div><div class="card-body">`;
|
||||
ips.slice(0,8).forEach(i=>{cards+=`<p><strong>[${esc(i.category)}]</strong> ${esc(i.description)}<br><small style="color:var(--mute)">${esc(i.evidence||"")}</small></p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
break;}
|
||||
case "s2":{
|
||||
const scholars=o.core_scholars||[];
|
||||
if(scholars.length){
|
||||
cards+=`<div class="conclusion-card collab"><div class="card-title">👥 核心学者</div><div class="card-body">`;
|
||||
scholars.slice(0,6).forEach(s=>{cards+=`<p><strong>${esc(s.login)}</strong> — ${s.repo_count} 个仓库</p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
const topics=o.topic_heat||[];
|
||||
if(topics.length){
|
||||
cards+=`<div class="conclusion-card innovation"><div class="card-title">🔥 热门主题</div><div class="card-body">`;
|
||||
topics.slice(0,6).forEach(t=>{cards+=`<p><strong>${esc(t.topic)}</strong> — ${t.count} 个仓库</p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
break;}
|
||||
case "s3":{
|
||||
const risks=o.risk_items||o.risks||[];
|
||||
if(risks.length){
|
||||
cards+=`<div class="conclusion-card risk"><div class="card-title">⚠️ 风险项 (${risks.length})</div><div class="card-body">`;
|
||||
risks.slice(0,8).forEach(r=>{
|
||||
const lvl=(r.level||"").includes("high")||r.severity==="P0"?"🔴":"🟡";
|
||||
cards+=`<p>${lvl} <strong>${esc(r.category||r.name||"")}</strong> — ${esc(r.detail||r.evidence||"")}</p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
break;}
|
||||
case "s4":{
|
||||
const cands=o.candidates||[];
|
||||
if(cands.length){
|
||||
cards+=`<div class="conclusion-card collab"><div class="card-title">🤝 协作推荐 (${cands.length})</div><div class="card-body">`;
|
||||
cands.slice(0,6).forEach((c,i)=>{cards+=`<p><strong>#${i+1} ${esc(c.login)}</strong> (${c.score}分)<br><small>${(c.reasons||[]).join(";")}</small></p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
const gaps=o.gap_topics||[];
|
||||
if(gaps.length){
|
||||
cards+=`<div class="conclusion-card innovation"><div class="card-title">🔎 缺口主题 (${gaps.length})</div><div class="card-body">`;
|
||||
gaps.slice(0,6).forEach(g=>{cards+=`<p>${esc(typeof g==="string"?g:(g.topic||g.name||JSON.stringify(g)))}</p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
break;}
|
||||
case "s5":{
|
||||
const risks=o.risk_warnings||[];
|
||||
if(risks.length){
|
||||
cards+=`<div class="conclusion-card risk"><div class="card-title">⚠️ 风险预警 (${risks.length})</div><div class="card-body">`;
|
||||
risks.slice(0,6).forEach(w=>{cards+=`<p><strong>[${esc(w.type)}]</strong> ${esc(w.message)}${w.suggestion?"<br><small>"+esc(w.suggestion)+"</small>":""}</p>`;});
|
||||
cards+=`</div></div>`;
|
||||
}
|
||||
const trend=o.trend||{};
|
||||
cards+=`<div class="conclusion-card innovation"><div class="card-title">📈 活跃趋势</div><div class="card-body"><p>commit 变化: <strong>${trend.commit_delta_pct||"—"}</strong></p><p>活跃度: <strong>${esc(trend.activity_level||"—")}</strong></p></div></div>`;
|
||||
break;}
|
||||
case "s6":{
|
||||
const tl=o.timeline||{};
|
||||
cards+=`<div class="conclusion-card innovation"><div class="card-title">📊 数据概览</div><div class="card-body"><p>时间线: ${(tl.labels||[]).length} 周</p><p>语言分布: ${(o.language_pie||[]).length} 类</p><p>累计 commits: ${(tl.commits||[]).reduce((a,b)=>a+(b||0),0)}</p></div></div>`;
|
||||
break;}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/* go */
|
||||
loadResult();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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` 环境变量,无需改。
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 维度评估)
|
||||
|
||||
**① 触发语**:
|
||||
> "我想参与 <owner>/<repo> 这个项目,帮我找几个适合新手的任务"
|
||||
|
||||
**② 预期 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 <owner> --repo <repo> --keyword "good first issue" --category opened
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo>
|
||||
gitlink-cli repo +readme --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
**④ 讲解要点**:
|
||||
- **5 维度评估表**是设计亮点:把"哪个 Issue 适合新人"从主观判断变成可量化打分(指着 AI 输出的评分讲)
|
||||
- 引导评论模板:AI 能生成「欢迎贡献 + 代码定位 + 修改步骤」个性化评论(写操作,会先确认)
|
||||
- 若无 good-first-issue 标签:AI 应从开放 Issue 推荐最简单的(决策规则)
|
||||
|
||||
---
|
||||
|
||||
## 三、digest 实演(亮点:跨源聚合成简报)
|
||||
|
||||
**① 触发语**:
|
||||
> "给我一份 <owner>/<repo> 的项目简报,今天有什么动态"
|
||||
|
||||
**② 预期 AI 行为**:读 `digest/SKILL.md` → 并行采集 → 聚合分类 →
|
||||
1. `issue +list --state open` + `pr +list`(Issue/PR 动态)
|
||||
2. `ci +builds`(CI 状态)
|
||||
3. `api GET "users/<me>/messages.json"`(通知)
|
||||
4. 按 🔴需关注 / 🟢新增 / 🔵进行中 / 📊指标 分类,输出 Markdown 简报
|
||||
|
||||
**③ 手动备选**:
|
||||
```bash
|
||||
gitlink-cli issue +list --state open --format json
|
||||
gitlink-cli pr +list --format json
|
||||
gitlink-cli ci +builds --owner <owner> --repo <repo> --format json
|
||||
gitlink-cli api GET "users/<me>/messages.json"
|
||||
```
|
||||
|
||||
**④ 讲解要点**:
|
||||
- **跨源聚合**是亮点:一份简报汇总 Issue/PR/CI/通知,不用挨个刷
|
||||
- 与团队 `notification-digest` 分工:它做通知中心(标记已读),digest 做项目全景(不做标记已读)—— 体现去重思考
|
||||
- 纯只读,安全可随时跑
|
||||
|
||||
---
|
||||
|
||||
## 四、todo 实演(亮点:补上「我的」视角)
|
||||
|
||||
**① 触发语**:
|
||||
> "我的待办有哪些?哪些 Issue/PR 在等我处理"
|
||||
|
||||
**② 预期 AI 行为**:读 `todo/SKILL.md` →
|
||||
1. `api GET "users/me"`(识别身份)
|
||||
2. `search +issues --assignee <me> --category opened`(分配我的)
|
||||
3. `api GET "users/<me>/messages.json"`(@我的)
|
||||
4. `pr +list`(我的 PR 状态)
|
||||
5. 按紧急度(@我 > 待 review > 指派)排序,输出待办清单
|
||||
|
||||
**③ 手动备选**:
|
||||
```bash
|
||||
gitlink-cli api GET "users/me" --format json
|
||||
gitlink-cli search +issues --assignee <me> --category opened
|
||||
gitlink-cli api GET "users/<me>/messages.json"
|
||||
```
|
||||
|
||||
**④ 讲解要点**:
|
||||
- **「我的」视角**是 gitlink 最缺的:跨 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)可放心反复跑
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 4 个 Skill 登录实演 · 辅助采集脚本
|
||||
# 作用:把每个 Skill 的「只读采集命令」串起来自动跑,展示真实数据
|
||||
# 分析(评估/聚合/排序)部分由 AI 在 Claude Code 里做——那才是亮点
|
||||
# 用法:bash demo/live-demo.sh <owner> <repo> [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 <owner> <repo> [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 "按回车键继续..."
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# 代码质量看门人 · 工作流说明与架构(子任务三)
|
||||
|
||||
> 端到端自动化工作流:PR 提交后自动跑完「采集 → AI Review → CI → 评论 → 质量判定/合并」。
|
||||
> 对应 Skill:`skills/gitlink-pr-guard/SKILL.md`;可复现脚本:`demo/pr-guard-workflow.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 一、工作流架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 触发:PR 提交 / 更新 │
|
||||
│ (或用户:帮我把关 PR #<id>) │
|
||||
└──────────────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 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 <owner> --repo <repo> --state open
|
||||
|
||||
# 3. 跑质量看门人流水线(脚本采集,AI 在 Claude Code 做 Step2 分析)
|
||||
bash demo/pr-guard-workflow.sh <owner> <repo> <pr_id>
|
||||
|
||||
# 或在 Claude Code 里自然语言触发:
|
||||
# "读 skills/gitlink-pr-guard/SKILL.md,帮我把关 <owner>/<repo> 的 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` — 本文档(说明 + 架构图)
|
||||
- [ ] 真实项目演示(登录后运行 + 截图/录屏)
|
||||
- [ ] 报告(暂缓,后续按统一策略补《新需求构思》《变更影响测试》)
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 代码质量看门人 · 端到端工作流脚本(子任务三)
|
||||
# 串联 5 步:采集 PR → AI Review → CI 检查 → 汇总评论 → 质量判定
|
||||
# 用法:bash demo/pr-guard-workflow.sh <owner> <repo> <pr_id>
|
||||
# 例: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 <owner> <repo> <pr_id>${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 "按回车键继续..."
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# 科研仓库画像 · 使用指南(子任务四)
|
||||
|
||||
> 把 GitLink 上的开源科研项目,转化成**可评估、可引用、可复现**的科研画像。
|
||||
> 对应 Skill:`skills/gitlink-research-insight/SKILL.md`;脚本:`demo/research-insight-workflow.sh`。
|
||||
> 定位:科研辅助(加分项),面向科研工作者/课题组,无需 Go,Markdown + CLI。
|
||||
|
||||
---
|
||||
|
||||
## 一、它能做什么
|
||||
|
||||
科研工作者面对 GitLink 上的一个开源科研项目,常问三个问题:
|
||||
1. **能不能复现?**(别人的实验我跑得出同样结果吗)
|
||||
2. **能不能引用?**(写论文能引这个项目/代码吗,引哪个版本)
|
||||
3. **能不能合作?**(项目活跃吗,核心圈开放吗)
|
||||
|
||||
本工具用 gitlink-cli 采集仓库数据,从**可复现性 / 活跃度 / 引用价值 / 协作健康**四个维度评估,并生成**贡献者协作知识图谱**,产出一份《科研仓库画像报告》。
|
||||
|
||||
---
|
||||
|
||||
## 二、快速使用
|
||||
|
||||
```bash
|
||||
# 1. 登录
|
||||
gitlink-cli auth login
|
||||
|
||||
# 2. 对一个科研仓库跑画像(脚本采集,AI 评分)
|
||||
bash demo/research-insight-workflow.sh <owner> <repo>
|
||||
|
||||
# 或在 Claude Code 里自然语言触发:
|
||||
# "读 skills/gitlink-research-insight/SKILL.md,帮我评估 <owner>/<repo> 这个科研项目值不值得引用和复现"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、工作流架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 输入:一个 GitLink 科研仓库 │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Step 0 fork 检测(避免给 fork 错评) │
|
||||
│ repo +info → fork_info │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Step 1 数据采集(只读) │
|
||||
│ repo +info/languages/contributors │
|
||||
│ issue +list / pr +list │
|
||||
│ file +get LICENSE/CI / release +list │
|
||||
│ git clone 兜底(repo +commits 不存在)│
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Step 2 四维科研评分 │
|
||||
│ 🔁 可复现性 📈 活跃度 │
|
||||
│ 📑 引用价值 🤝 协作健康 │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Step 3 协作知识图谱(mermaid) │
|
||||
│ 贡献者协作网络 + 巴士因子 │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Step 4 科研仓库画像报告 │
|
||||
│ 定性 + 综合评分 + 四维详情 + 建议 │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、四维评分体系(科研创新点)
|
||||
|
||||
### 🔁 可复现性(满分 10,科研最核心)
|
||||
| 检查项 | 分值 | 判定方法 |
|
||||
|--------|:---:|---------|
|
||||
| CI 配置 | +2 | `.gitea/workflows` 或 `.github/workflows` 存在 |
|
||||
| 依赖锁定 | +2 | `go.sum` / `package-lock.json` / `requirements.txt` |
|
||||
| 数据/数据说明 | +2 | README 提及数据集,或有 `data/` |
|
||||
| 运行文档 | +2 | README 有安装/运行/环境说明 |
|
||||
| 版本归档 | +2 | 有 release/tag(可引用固定版本) |
|
||||
|
||||
### 📈 活跃度
|
||||
近 3 月提交频率 + Issue/PR 活跃 + 贡献者趋势
|
||||
|
||||
### 📑 引用价值
|
||||
LICENSE + 版本归档 + 文档完整 + 星标/关注
|
||||
|
||||
### 🤝 协作健康
|
||||
Issue 响应时长 + PR 合并率 + **巴士因子**(核心贡献者提交占比,越分散越健康)
|
||||
|
||||
> **与 gitlink-health 区别**:health 看"工程维护好不好",本工具看"科研上值不值得用"(可复现/引用/合作)。
|
||||
|
||||
---
|
||||
|
||||
## 五、报告样例(填好的示例)
|
||||
|
||||
```markdown
|
||||
# 🔬 科研仓库画像 — someresearch/awesome-paper-code
|
||||
|
||||
> 一句话定性:这是一个活跃维护的 NLP 科研项目,适合引用与复现。
|
||||
|
||||
## 📊 综合评分:⭐ 8.5/10
|
||||
| 维度 | 得分 | 评价 |
|
||||
|------|:---:|------|
|
||||
| 🔁 可复现性 | 8/10 | CI+依赖锁定+数据说明齐全,可复现 |
|
||||
| 📈 活跃度 | 9/10 | 近3月持续提交,活跃 |
|
||||
| 📑 引用价值 | 9/10 | MIT 协议 + v2.1 版本,适合引用 |
|
||||
| 🤝 协作健康 | 8/10 | 巴士因子健康(核心占 45%) |
|
||||
|
||||
## 🔁 可复现性详情
|
||||
- CI:✅ GitHub Actions
|
||||
- 依赖锁定:✅ requirements.txt
|
||||
- 数据说明:✅ README 有数据集下载指引
|
||||
- 版本归档:✅ 最新 v2.1
|
||||
|
||||
## 💡 给科研工作者的建议
|
||||
1. 引用:建议引 v2.1(MIT 协议)
|
||||
2. 复现:按 README + CI 可复现
|
||||
3. 合作:协作网络开放,可联系 @核心贡献者
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、真实仓库验证步骤(交付要求)
|
||||
|
||||
```bash
|
||||
# 1. 选一个 GitLink 上的科研类仓库(有 LICENSE/CI/数据 的最佳)
|
||||
# 2. 跑画像脚本
|
||||
bash demo/research-insight-workflow.sh <科研仓库owner> <科研仓库>
|
||||
|
||||
# 3. 在 Claude Code 让 AI 读 SKILL.md 完成评分,产出报告
|
||||
# 4. 截图/录屏报告 → 作为演示成果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、与子任务二/三的关系
|
||||
|
||||
| 子任务 | 视角 | 本任务复用 |
|
||||
|--------|------|-----------|
|
||||
| 任务二 health | 工程健康度 | 复用其指标采集 + 加科研维度 |
|
||||
| 任务三 pr-guard | 端到端工作流 | 复用"多步串联 + Skill + 脚本"模式 |
|
||||
| **任务四 本工具** | 科研价值 | 综合复用,加可复现性/引用/知识图谱 |
|
||||
|
||||
---
|
||||
|
||||
## 八、交付清单
|
||||
|
||||
- [x] `skills/gitlink-research-insight/SKILL.md` — 科研洞悉 Skill(自定义,遵循规范)
|
||||
- [x] `demo/research-insight-workflow.sh` — 可复现脚本(5 步:fork 检测 + 采集 + 评分 + 图谱 + 报告)
|
||||
- [x] `demo/research-insight-guide.md` — 本文档(完整中文 + 架构 + 报告样例)
|
||||
- [x] 真实科研仓库验证 — 已对 `whale_hihihi/gitlink-cli` 跑通,见 `demo/research-insight-verification.md` + 报告 `demo/research-insight-report-whale_gitlink-cli.md`
|
||||
- [x] 命令缺陷修复 — SKILL.md 与脚本原用的 `repo +commits`/`+raw`/`+tags` 三条不存在命令,已改为 `file +get`/`release +list`/`git clone` 兜底并实测通过
|
||||
- [ ] 演示录屏/截图(脚本与 AI 评分均可现场复现)
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# 🔬 科研仓库画像 — whale_hihihi/gitlink-cli
|
||||
|
||||
> **一句话定性**:这是官方 `Gitlink/gitlink-cli` 的一个 **fork**(团队/课程竞赛工作副本),是一个 **工程类 CLI 工具 + AI Agent Skills 集合**,并非科研产出(无数据集/实验/论文/CITATION)。**可高置信复现与构建,但不适合作为独立科研对象引用——引用应指向 upstream。**
|
||||
>
|
||||
> *由 gitlink-research-insight Skill 评估,数据采集于 2026-07-05(仓库最近提交 2026-07-02,仍活跃)。*
|
||||
|
||||
## 📊 综合评分:⭐ 6.5/10(作为工程仓库优秀;作为"科研引用对象"偏低)
|
||||
|
||||
| 维度 | 得分 | 评价 |
|
||||
|------|:---:|------|
|
||||
| 🔁 可复现性 | 8/10 | CI + go.sum + 一键 npm 安装 + 完整文档,构建复现性强 |
|
||||
| 📈 活跃度 | 9/10 | 近 3 个月 ~300 次提交、102 个 PR 合并,当日仍在提交 |
|
||||
| 📑 引用价值 | 3/10 | 是 fork、无 release、无 CITATION、0 星标,引用应指 upstream |
|
||||
| 🤝 协作健康 | 6/10 | 多人 PR 流程活跃,但核心维护者高度集中、无 Issue 追踪 |
|
||||
|
||||
## 📋 基础信息
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| 描述 | gitlink-cli 是 GitLink 平台的官方 CLI(fork 自 `Gitlink/gitlink-cli`,project_id 1513956) |
|
||||
| 主要语言 | Go 97.3% · JavaScript 2.2% · Shell 0.4% · Makefile 0.1% |
|
||||
| 许可证 | Mulan PSL v2(木兰宽松许可证,清晰开源) |
|
||||
| 贡献者数 | 20(按提交计);GitLink 注册用户 3 |
|
||||
| 版本归档 | ⚠️ 1 个 tag(`v0.1.14`),但 **0 个正式 release** |
|
||||
| 规模 / 关注 | 26 MB · ⭐ 0 · 👁 0 · fork 0 |
|
||||
|
||||
## 🔁 可复现性详情(科研核心)
|
||||
|
||||
| 检查项 | 判定 | 依据 |
|
||||
|--------|------|------|
|
||||
| CI 配置 | ✅ 有 | `.gitea/workflows/ci.yml`(GitLink)+ `.github/workflows/{ci,release,test}.yml`,跑 Build/Lint/Test/fmt(Go 1.22) |
|
||||
| 依赖锁定 | ✅ go.sum | Go 模块校验文件齐全 |
|
||||
| 数据说明 | ❌ N/A | 是 CLI 工具,本无数据集(评分表工程类适配,不扣分) |
|
||||
| 运行/环境文档 | ✅ 完整 | README 33KB + zh-CN,含 Requirements(Node 14+/Go 1.26+)、Installation、Quick Start、Makefile、Dockerfile |
|
||||
| 版本归档 | ⚠️ 部分 | 有 tag `v0.1.14` 可 pin,但无 release 产物 |
|
||||
|
||||
> **可复现性结论**:**构建/运行层面极易复现**——`npm install -g @gitlink-ai/cli` 一键装、CI 全绿、文档完备。"复现实验结果"不适用(非实验型科研)。
|
||||
|
||||
## 📈 活跃度详情
|
||||
|
||||
- 提交分布:2026-04 → ~16 次,2026-05 → ~140 次,2026-06 → ~133 次,持续走高
|
||||
- 近 3 月提交 ~300 次,**最近一次提交 2026-07-02**(评估前 3 天)
|
||||
- 102 个 PR 合并提交,20 名贡献者——非常活跃,但仓库仅诞生 ~2.5 个月,属**新兴-高活跃**
|
||||
|
||||
## 🤝 协作网络图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 核心["核心圈(提交+评审)"]
|
||||
T[wbtiger<br/>~38% commits / 主评审]
|
||||
S[Surponess<br/>提交+评审]
|
||||
W[whale<br/>提交+评审]
|
||||
X[wauxing<br/>重构/合并 upstream]
|
||||
end
|
||||
subgraph 外围["PR 贡献者(fork/branch 提交)"]
|
||||
Y[wangyue111]
|
||||
M[muel]
|
||||
G[yangsai/Mengz]
|
||||
O[其余 10+ 人]
|
||||
end
|
||||
R((whale_hihihi/gitlink-cli))
|
||||
T -->|主提交+主评审| R
|
||||
S -->|提交+评审| R
|
||||
W -->|提交+评审| R
|
||||
X -->|重构/合并| R
|
||||
Y & M & G & O -.->|PR| R
|
||||
T -.评审/合并.-> Y
|
||||
T -.评审/合并.-> M
|
||||
```
|
||||
|
||||
- **巴士因子**:top1 `wbtiger` 占 ~38% 提交;评审更集中——`wbtiger` 一人合并了多数 PR。
|
||||
- ⚠️ **单点风险**:未越过"单人 >50%"红线,但核心维护者高度依赖 `wbtiger` 一人,且**无 Issue 追踪**(issues_count=0),协作瓶颈与知识单点并存。
|
||||
|
||||
## 💡 给科研工作者的建议
|
||||
|
||||
1. **引用**:❌ **不建议引用本 fork**。它是 `Gitlink/gitlink-cli` 的工作副本,无独立学术贡献、无 CITATION.cff、无 release、0 社区关注。如需引用该工具,**请引用 upstream `Gitlink/gitlink-cli`**,按 Mulan PSL v2 协议、pin 到 `v0.1.14` tag。
|
||||
2. **复现/构建**:✅ **强烈可复现**。`npm install -g @gitlink-ai/cli` 或 `make build`(Go 1.22+),配合 CI 与 README 几分钟可跑通。这是该仓库最大优点。
|
||||
3. **合作**:⚠️ 协作网络活跃但**核心圈封闭**——PR 走 fork 流程规范,但评审权集中在 `wbtiger`;新贡献者门槛中等。
|
||||
|
||||
---
|
||||
|
||||
### 执行备注(skill 命令缺陷,已修复)
|
||||
|
||||
按 skill 工作流执行时,原 SKILL.md/脚本的 `repo +commits`/`+tags`/`+raw` 三条命令在本二进制中不存在,GitLink 的 commits/pulls API 路径亦返回 HTML。本次评估改用 `file +get`(读文件)、`release +list`(版本)、`git clone`(提交时间线)完成采集,缺陷已同步修复进 SKILL.md 与脚本。详见 `research-insight-verification.md`。
|
||||
|
||||
---
|
||||
*由 gitlink-research-insight 科研画像生成 · 评估对象 whale_hihihi/gitlink-cli · 2026-07-05*
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 任务四 · research-insight 真实验证记录
|
||||
|
||||
> 对应 Skill:`skills/gitlink-research-insight/SKILL.md`;脚本:`demo/research-insight-workflow.sh`。
|
||||
> 验证对象:`whale_hihihi/gitlink-cli`(GitLink 上的真实仓库)。
|
||||
> 验证时间:2026-07-05。方法:对照模板法(见《验证剧本.md》三层证据)。
|
||||
> 配套报告原件:`demo/research-insight-report-whale_gitlink-cli.md`。
|
||||
|
||||
---
|
||||
|
||||
## 一、验证对象与结论
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| 验证仓库 | `whale_hihihi/gitlink-cli`(公开、真实、有 LICENSE/CI/贡献者) |
|
||||
| 关键发现 | 是 `Gitlink/gitlink-cli` 的 **fork** —— 触发 SKILL.md「Step 0 fork 检测」,引用价值改评 upstream |
|
||||
| 总体结论 | ✅ **达标**:命令层 / 编排层 / 输出层 三层证据齐备,报告符合 SKILL.md 模板 |
|
||||
| 附带产出 | 发现并修复 SKILL.md + 脚本 3 条不存在命令(见第四节) |
|
||||
|
||||
---
|
||||
|
||||
## 二、三层证据(对照《验证剧本.md》模型)
|
||||
|
||||
| 层 | 证明什么 | 证据 | 结果 |
|
||||
|----|---------|------|:---:|
|
||||
| ① 命令层 | 命令真实执行、返回真实数据 | `repo +info/+languages/+contributors`、`file +get LICENSE`、`repo +tree .gitea/workflows`、`release +list`、`issue/pr +list` 均返回真实 JSON(非 404/unknown) | ✅ |
|
||||
| ② 编排层 | AI 按 SKILL.md **顺序**调命令 | 触发语后,AI 严格走 Step 0(fork 检测) → Step 1(采集) → Step 2(四维评分) → Step 3(协作图谱) → Step 4(报告) | ✅ |
|
||||
| ③ 输出层 | 输出**符合 SKILL.md 模板** | 报告含:综合评分 + 四维得分表 + 可复现性逐项 + mermaid 协作图 + 引用/复现/合作建议 + fork 改评 upstream 结论 | ✅ |
|
||||
|
||||
> ③ 是判定「达到效果」的硬标准——AI 实际输出与 SKILL.md「输出模板」并排对照,关键字段全部命中。
|
||||
|
||||
---
|
||||
|
||||
## 三、命令层证据(采集到的真实数据片段)
|
||||
|
||||
```bash
|
||||
# fork 检测(Step 0):脚本正确识别为 fork
|
||||
⚠️ whale_hihihi/gitlink-cli 是 Gitlink/gitlink-cli 的 fork —— 引用价值应改评 upstream Gitlink/gitlink-cli
|
||||
|
||||
# repo +info 关键字段
|
||||
contributor_users_count: 3 # GitLink 注册贡献者
|
||||
pull_requests_count: 21 # PR 计数
|
||||
version_releases_count: 0 # 无正式 release
|
||||
fork_info.fork_project_user_login: "Gitlink" # ← fork 来源
|
||||
|
||||
# repo +languages
|
||||
Go 97.3% / JavaScript 2.2% / Shell 0.4% / Makefile 0.1%
|
||||
|
||||
# repo +contributors(巴士因子输入)
|
||||
wbtiger 131 38.42% ← 核心,单人 <50%,未越红线
|
||||
wauxing 35 10.x %
|
||||
whale 34 10.x %
|
||||
...
|
||||
|
||||
# file +get LICENSE → Mulan PSL v2(木兰宽松许可证)
|
||||
# repo +tree .gitea/workflows → ci.yml(CI 配置存在)
|
||||
# release +list → releases: [](确认 0 release,与 info 一致)
|
||||
|
||||
# git 兜底(repo +commits 不存在)
|
||||
近 3 月提交: 300 次 | 最近提交: 2026-07-02(评估前 3 天) | tag: v0.1.14 | PR 合并数: 102
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、验证中发现的命令缺陷与修复(重要)
|
||||
|
||||
验证过程暴露 SKILL.md 与脚本原用了 **3 条 gitlink-cli 不存在的命令**,脚本 Step 1 会连续报错、采不到数据。已全部修复并实测通过:
|
||||
|
||||
| 原命令(不存在) | 现象 | 修复后(可用) | 实测 |
|
||||
|------------------|------|----------------|:---:|
|
||||
| `repo +commits --limit` | `unknown flag`,无此子命令 | `git clone --depth 100` + `git log`(API 无 commits JSON 端点) | ✅ 300 次/3 月 |
|
||||
| `repo +raw --path` | 无此子命令 | `file +get --path`(自动解码 base64)+ `repo +tree --path` 列目录 | ✅ 读到 LICENSE |
|
||||
| `repo +tags` | 无此子命令 | `release +list` + `repo +info.version_releases_count` | ✅ 确认 0 release |
|
||||
|
||||
**同步修复的文件**:
|
||||
- `skills/gitlink-research-insight/SKILL.md` —— Step 1 命令清单 + 降级方案 + 工程类仓库评分适配(已由前期协作完成)
|
||||
- `demo/research-insight-workflow.sh` —— Step 1 采集命令 + 新增 Step 0 fork 检测
|
||||
- `demo/research-insight-guide.md` —— 架构图 + 交付清单
|
||||
|
||||
**另发现的设计缺口(已在 SKILL.md 补)**:
|
||||
- 原 Skill 无 **fork 检测**步骤 → 对 fork 仓库会给出错误引用结论;已加 Step 0。
|
||||
- 评分表「数据/数据获取说明」项面向数据科学项目,套到 CLI 工具会扣冤枉分 → 已加「工程类 N/A」适配。
|
||||
|
||||
---
|
||||
|
||||
## 五、脚本一键复现
|
||||
|
||||
```bash
|
||||
# 登录后对任意 GitLink 仓库复现本次验证
|
||||
bash demo/research-insight-workflow.sh whale_hihihi gitlink-cli
|
||||
# AI 层(四维评分 + 协作图谱 + 报告)在 Claude Code 触发:
|
||||
# 读 skills/gitlink-research-insight/SKILL.md,评估 whale_hihihi/gitlink-cli 值不值得引用和复现
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、对照《验证剧本.md》结论表(任务四行)
|
||||
|
||||
| 任务 | 验证项 | 命令层 | 编排层 | 输出层 | 结论 |
|
||||
|------|--------|:---:|:---:|:---:|------|
|
||||
| 四 | research-insight | ✅ | ✅ | ✅ | 四维画像达标(含 fork 检测 + 命令缺陷修复) |
|
||||
|
||||
---
|
||||
|
||||
*本验证记录于 2026-07-05 生成,数据为当日实采。仓库活跃(最近提交 2026-07-02),数字可能随时间增长。*
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 科研仓库画像 · 端到端工作流脚本(子任务四)
|
||||
# 4 步:采集数据 → 四维评分 → 协作图谱 → 科研画像报告
|
||||
# 用法:bash demo/research-insight-workflow.sh <owner> <repo>
|
||||
# 例: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 <owner> <repo>${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 "按回车键继续..."
|
||||
|
|
@ -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 "按回车键继续..."
|
||||
|
|
@ -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` 流水线 + 服务器部署说明)。
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink CLI · 智能化能力展示</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --txt:#c9d1d9; --acc:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; }
|
||||
body { background:var(--bg); color:var(--txt); font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); }
|
||||
.mono { font-family: "Cascadia Code", Consolas, "Courier New", monospace; }
|
||||
#term-out { background:#010409; min-height:300px; max-height:440px; overflow-y:auto; padding:14px; }
|
||||
.l-cmd { color:var(--acc); } .l-out { color:var(--txt); white-space:pre-wrap; word-break:break-all; }
|
||||
.l-err { color:var(--red); } .l-muted { color:#6e7681; font-style:italic; }
|
||||
#term-input { background:#010409; border-top:1px solid var(--border); }
|
||||
.skill-card { transition:.2s; }
|
||||
.skill-card:hover { border-color:var(--acc); transform:translateY(-2px); }
|
||||
.step { transition:.3s; }
|
||||
.step.active { border-color:var(--green); background:#1c2820; }
|
||||
.verb { font-size:11px; padding:2px 7px; border-radius:4px; border:1px solid var(--border); background:#0d1117; cursor:pointer; transition:.15s; }
|
||||
.verb:hover { border-color:var(--acc); color:var(--acc); }
|
||||
.chip { background:#21262d; border:1px solid var(--border); }
|
||||
.fbtn { font-size:12px; padding:4px 12px; border-radius:9999px; border:1px solid var(--border); cursor:pointer; transition:.15s; }
|
||||
.fbtn.active { background:var(--acc); color:#000; border-color:var(--acc); }
|
||||
details > summary { list-style:none; }
|
||||
details > summary::-webkit-details-marker { display:none; }
|
||||
details[open] > summary .arr { transform:rotate(90deg); }
|
||||
.arr { display:inline-block; transition:.15s; }
|
||||
#skill-modal-body h1,#skill-modal-body h2,#skill-modal-body h3,#skill-modal-body h4 { color:#fff; margin:.7em 0 .35em; font-weight:600; }
|
||||
#skill-modal-body h1 { font-size:1.3em; border-bottom:1px solid var(--border); padding-bottom:.2em; }
|
||||
#skill-modal-body h2 { font-size:1.15em; } #skill-modal-body h3 { font-size:1.02em; }
|
||||
#skill-modal-body p { margin:.4em 0; line-height:1.6; }
|
||||
#skill-modal-body table { border-collapse:collapse; margin:.5em 0; display:block; overflow-x:auto; }
|
||||
#skill-modal-body th,#skill-modal-body td { border:1px solid var(--border); padding:4px 8px; text-align:left; font-size:.85em; }
|
||||
#skill-modal-body th { background:#21262d; }
|
||||
#skill-modal-body code { background:#010409; padding:1px 5px; border-radius:3px; color:var(--green); font-size:.85em; }
|
||||
#skill-modal-body pre { background:#010409; padding:10px; border-radius:6px; overflow-x:auto; margin:.5em 0; border:1px solid var(--border); }
|
||||
#skill-modal-body pre code { background:none; padding:0; color:var(--txt); }
|
||||
#skill-modal-body strong { color:#fff; }
|
||||
#skill-modal-body blockquote { border-left:3px solid var(--acc); padding-left:10px; color:#8b949e; margin:.5em 0; }
|
||||
#skill-modal-body ul,#skill-modal-body ol { padding-left:1.4em; margin:.4em 0; }
|
||||
#skill-modal-body hr { border-color:var(--border); margin:.8em 0; }
|
||||
#skill-modal-body a { color:var(--acc); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="border-b border-[var(--border)] panel">
|
||||
<div class="max-w-7xl mx-auto px-6 py-5 flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">🚀 GitLink CLI · 智能化能力展示</h1>
|
||||
<p class="text-sm text-[#8b949e] mt-1">从「开发者工具」升级为「AI 可驱动平台」—— 命令 · Skills · 工作流 · 科研辅助</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap text-xs">
|
||||
<span class="chip px-3 py-1.5 rounded-full">🛠 25 域 / 160+ 命令</span>
|
||||
<span class="chip px-3 py-1.5 rounded-full">🧠 53 Skills</span>
|
||||
<span class="chip px-3 py-1.5 rounded-full">🚪 pr-guard 工作流</span>
|
||||
<span class="chip px-3 py-1.5 rounded-full">🔬 科研四维画像</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto px-6 pb-4 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="text-[#8b949e] whitespace-nowrap">🔑 GitLink Token</span>
|
||||
<input id="token-input" type="password" placeholder="粘贴你的 GitLink 个人访问令牌(仅存本机浏览器;公开仓库命令可免)" class="flex-1 min-w-[220px] bg-[#0d1117] border border-[var(--border)] rounded px-3 py-1.5 text-xs mono">
|
||||
<span class="text-[#8b949e] whitespace-nowrap">📦 默认目标</span>
|
||||
<input id="owner-input" value="Gitlink" class="w-28 bg-[#0d1117] border border-[var(--border)] rounded px-2 py-1.5 text-xs mono" placeholder="owner">
|
||||
<span class="text-[#6e7681]">/</span>
|
||||
<input id="repo-input" value="gitlink-cli" class="w-36 bg-[#0d1117] border border-[var(--border)] rounded px-2 py-1.5 text-xs mono" placeholder="repo">
|
||||
<span id="token-state" class="text-xs"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-6 py-8 space-y-10">
|
||||
|
||||
<!-- ① 命令浏览器 + 参数构建 + 终端 -->
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-white mb-1">▶ 命令浏览器 <span class="text-sm text-[#8b949e] font-normal">(先选大方向 → 展开域 → 点动词填参数 → 真跑)</span></h2>
|
||||
<p class="text-sm text-[#8b949e] mb-4">左侧点分类展开命令域;点动词在右侧「参数构建」里填参(搜索类命令的 keyword 等由你决定),回车或点▶运行。本地命令免 token。</p>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- 左:折叠分层浏览器 -->
|
||||
<div class="panel rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="font-semibold text-white">命令域</h3>
|
||||
<input id="cmd-search" placeholder="🔍 搜索" class="flex-1 bg-[#0d1117] border border-[var(--border)] rounded px-2 py-1 text-xs mono">
|
||||
</div>
|
||||
<div id="cmd-groups" class="text-sm max-h-[460px] overflow-y-auto pr-1"></div>
|
||||
</div>
|
||||
<!-- 右:参数构建 + 终端 -->
|
||||
<div class="panel rounded-lg lg:col-span-2 flex flex-col">
|
||||
<!-- 参数构建条 -->
|
||||
<div class="px-4 py-3 border-b border-[var(--border)] bg-[#0d1017]">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs text-[var(--yellow)] font-semibold">🧩 参数构建</span>
|
||||
<span id="builder-hint" class="text-xs text-[#6e7681]">点左侧动词开始</span>
|
||||
</div>
|
||||
<div id="builder-params" class="grid grid-cols-2 gap-2 mb-2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--green)] mono text-sm">$</span>
|
||||
<input id="builder-cmd" class="flex-1 bg-[#010409] border border-[var(--border)] rounded px-2 py-1.5 text-xs mono text-[var(--green)]" placeholder="组装好的命令会显示在这里(可手改)" autocomplete="off">
|
||||
<button onclick="runBuilder()" class="bg-[var(--green)] text-black px-3 py-1.5 rounded text-xs font-semibold whitespace-nowrap">▶ 运行</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 终端 -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] text-xs text-[#8b949e]">
|
||||
<span class="w-3 h-3 rounded-full bg-[var(--red)]"></span><span class="w-3 h-3 rounded-full bg-[var(--yellow)]"></span><span class="w-3 h-3 rounded-full bg-[var(--green)]"></span>
|
||||
<span class="ml-2">终端</span><span id="term-target" class="ml-auto text-[var(--acc)]"></span>
|
||||
</div>
|
||||
<div id="term-out" class="mono text-sm flex-1"></div>
|
||||
<div class="flex items-center px-4 py-2 mono text-sm" id="term-input">
|
||||
<span class="text-[var(--green)] mr-2">$</span>
|
||||
<input id="cmd-input" class="flex-1 bg-transparent text-[var(--green)] mono" placeholder="或在此自由输入 gitlink-cli 命令回车" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② Skill 卡片墙 -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||
<h2 class="text-xl font-bold text-white">🧠 Skill 全集 <span class="text-sm text-[#8b949e] font-normal">(53 个 · 点卡片读 SKILL.md 全文)</span></h2>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button class="fbtn active" onclick="filterSkill('all',this)">全部 53</button>
|
||||
<button class="fbtn" onclick="filterSkill('new',this)">✨ 本次新增 7</button>
|
||||
<button class="fbtn" onclick="filterSkill('research',this)">🔬 科研</button>
|
||||
<button class="fbtn" onclick="filterSkill('quality',this)">🚪 代码质量</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3" id="skill-wall"></div>
|
||||
</section>
|
||||
|
||||
<!-- ③ 任务三 pr-guard -->
|
||||
<section class="panel rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-1"><h2 class="text-xl font-bold text-white">🚪 任务三:代码质量看门人</h2><span class="chip px-2 py-0.5 rounded text-xs">pr-guard</span></div>
|
||||
<div class="text-sm text-[#8b949e] mb-3 space-y-1">
|
||||
<p><b class="text-[var(--txt)]">是什么</b>:PR 提交后自动跑完 5 步质量门禁 —— 串接 <code class="text-[var(--green)]">pr → code-review → ci → api → pr</code> 四个命令域。</p>
|
||||
<p><b class="text-[var(--txt)]">门禁规则</b>:<span class="text-[var(--green)]">0 Critical + CI success → ✅ 合并</span>;否则 <span class="text-[var(--red)]">🔴 拒绝</span>。</p>
|
||||
<p><b class="text-[var(--txt)]">与 code-review 区别</b>:code-review 只做 Review;pr-guard 是<b>完整闭环</b>(采集→审查→CI→评论→判定/合并)。</p>
|
||||
<p><b class="text-[var(--txt)]">任务三全家桶</b>(不止 pr-guard):code-review×pr-summary 闭环、community-ops-sweep 七段式周报(wauxing)+ 自动化 Skill 族 <code class="text-[var(--green)]">gatekeeper / commit-quality / issue-triage / issueops / release-auto / wiki-builder / pipeline-guardian / webhook-sentinel</code>(点下方 Skill 墙查看)。</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 mb-5 flex-wrap" id="pr-steps"></div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button onclick="runPipeline()" class="bg-[var(--acc)] text-black px-4 py-2 rounded font-semibold text-sm hover:opacity-90">▶ 模拟流水线</button>
|
||||
<button onclick="livePR()" class="border border-[var(--border)] text-[var(--txt)] px-4 py-2 rounded text-sm hover:bg-[#21262d]">🔌 用真实 PR 跑(需 token)</button>
|
||||
</div>
|
||||
<div id="pr-verdict" class="mt-4 text-sm mono"></div>
|
||||
</section>
|
||||
|
||||
<!-- ④ 任务四 科研画像 -->
|
||||
<section class="panel rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-1"><h2 class="text-xl font-bold text-white">🔬 任务四:科研仓库画像</h2><span class="chip px-2 py-0.5 rounded text-xs">research-insight</span></div>
|
||||
<div class="text-sm text-[#8b949e] mb-3 space-y-1">
|
||||
<p><b class="text-[var(--txt)]">是什么</b>:四维科研评分(🔁可复现性 / 📈活跃度 / 📑引用价值 / 🤝协作健康)+ 贡献者协作网络 + 巴士因子。</p>
|
||||
<p><b class="text-[var(--txt)]">区别于 health</b>:health 看「工程维护好不好」,本工具看「<b class="text-[var(--txt)]">科研上值不值得引用/复现</b>」;含 <b class="text-[var(--txt)]">fork 检测</b>(fork 自动改评 upstream)。</p>
|
||||
<p><b class="text-[var(--txt)]">任务四全家桶</b>(whale 主导,<b class="text-[var(--txt)]">S1–S6 全生命周期</b>):<code class="text-[var(--green)]">research-insight(S1) / research-graph(S2) / compliance(S3) / collab-match(S4) / research-progress(S5) / research-visual(S6)</code> + research-fork-impact/scholar-profile;Python 算法层 ~5800 行 + Go Web demo。</p>
|
||||
<p><b class="text-[var(--txt)]">真实验证</b>:<code class="text-[var(--green)]">whale_hihihi/gitlink-cli</code> → 识别为 fork、巴士因子 38%、可复现性 8/10。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-5 flex-wrap">
|
||||
<span class="text-sm text-[#8b949e]">分析对象 = 顶栏 owner/repo</span>
|
||||
<button onclick="analyze()" class="bg-[var(--green)] text-black px-4 py-2 rounded font-semibold text-sm hover:opacity-90">🔍 实拉分析</button>
|
||||
<span id="analyze-status" class="text-xs text-[#8b949e]">(公开仓库可免 token)</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div><canvas id="radar"></canvas><div id="repro-detail" class="text-xs text-[#8b949e] mt-3 mono"></div></div>
|
||||
<div>
|
||||
<h4 class="text-white font-semibold mb-2">协作网络图 <span id="bus-info" class="text-xs text-[#8b949e] font-normal">(点「实拉分析」用真实贡献者重绘)</span></h4>
|
||||
<svg id="collab-svg" viewBox="0 0 360 260" class="w-full panel rounded border border-[var(--border)]"></svg>
|
||||
<div id="repo-meta" class="text-xs text-[#8b949e] mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑤ 验证 -->
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-white mb-4">✅ 验证:三层证据(对照模板法)</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="panel rounded-lg p-4"><div class="text-[var(--green)] text-2xl mb-1">①</div><h4 class="text-white font-semibold">命令层</h4><p class="text-sm text-[#8b949e] mt-1">命令真实执行,返回真实数据(非 unknown/401)</p></div>
|
||||
<div class="panel rounded-lg p-4"><div class="text-[var(--yellow)] text-2xl mb-1">②</div><h4 class="text-white font-semibold">编排层</h4><p class="text-sm text-[#8b949e] mt-1">AI 按 SKILL.md 的顺序调命令</p></div>
|
||||
<div class="panel rounded-lg p-4"><div class="text-[var(--acc)] text-2xl mb-1">③</div><h4 class="text-white font-semibold">输出层</h4><p class="text-sm text-[#8b949e] mt-1">输出符合 SKILL.md 模板(判定/评分/清单)</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="text-center text-xs text-[#6e7681] py-6 border-t border-[var(--border)]">
|
||||
GitLink CLI 智能化能力展示 · 子任务一~四 + DevOps · 后端 demo/web/server.py(访客自带 token,零凭据上云)
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ===== token & owner/repo =====
|
||||
const tokenInput=document.getElementById('token-input'), ownerInput=document.getElementById('owner-input'), repoInput=document.getElementById('repo-input');
|
||||
tokenInput.value=localStorage.getItem('gl_token')||'';
|
||||
const tokenState=document.getElementById('token-state');
|
||||
function syncTokenState(){ tokenState.textContent=tokenInput.value?'✅ 已提供':'⚠️ 未提供(公开仓库命令仍可跑)'; tokenState.style.color=tokenInput.value?'var(--green)':'var(--yellow)'; }
|
||||
tokenInput.addEventListener('input',()=>{localStorage.setItem('gl_token',tokenInput.value);syncTokenState();}); syncTokenState();
|
||||
const getToken=()=>tokenInput.value.trim(), getOwner=()=>ownerInput.value.trim()||'Gitlink', getRepo=()=>repoInput.value.trim()||'gitlink-cli';
|
||||
function syncTarget(){ document.getElementById('term-target').textContent=getOwner()+'/'+getRepo(); } syncTarget();
|
||||
[ownerInput,repoInput].forEach(el=>el.addEventListener('input',()=>{syncTarget();updateCmd();}));
|
||||
|
||||
// ===== 命令域(25 域,按子任务分组)=====
|
||||
const DOMAIN_GROUPS=[
|
||||
{g:'资源管理(仓库/文件/版本)',open:true,domains:[
|
||||
{d:'repo',v:['info','languages','contributors','readme','tree','code-stats','list','create','delete','fork','stargazers','watchers']},
|
||||
{d:'file',v:['get','browse','create','update','delete']},
|
||||
{d:'branch',v:['list','create','delete','protect','unprotect']},
|
||||
{d:'release',v:['list','view','create','update','delete','download']},
|
||||
{d:'search',v:['issues','repos','users']},
|
||||
{d:'compare',v:['view','files']}]},
|
||||
{g:'Issue 跟踪',domains:[
|
||||
{d:'issue',v:['list','view','create','update','close','comment','batch-create','batch-update','batch-close','batch-assign','batch-label']},
|
||||
{d:'label',v:['list','create','update','delete']},
|
||||
{d:'milestone',v:['list','view','create','close','delete']}]},
|
||||
{g:'PR 与代码审查',domains:[
|
||||
{d:'pr',v:['list','view','diff','files','merge','reopen','comment','review','reviews','check-merge']}]},
|
||||
{g:'CI/CD · 流水线 · 工作流',domains:[
|
||||
{d:'ci',v:['builds','logs','restart','stop','enable','disable']},
|
||||
{d:'pipeline',v:['list','runs','run','view','logs','results','save-yaml']},
|
||||
{d:'workflow',v:['triage','health','pr-summary','repo-report']}]},
|
||||
{g:'项目管理 · 协作',domains:[
|
||||
{d:'pm',v:['boards','sprints','weekly','tags','pipelines','actions']},
|
||||
{d:'org',v:['list','info','members','create']},
|
||||
{d:'member',v:['list','add','remove']},
|
||||
{d:'webhook',v:['list','view','create','update','delete','history','test']},
|
||||
{d:'wiki',v:['list','view','create','update','delete']}]},
|
||||
{g:'用户与画像',domains:[
|
||||
{d:'user',v:['me','info','headmaps','stats-activity','stats-develop','trends']},
|
||||
{d:'profile',v:['activity','ability','contribution','role']}]},
|
||||
{g:'数据 · 合规 · 健康',domains:[
|
||||
{d:'dataset',v:['list','view','create','update']},
|
||||
{d:'license',v:['list']},
|
||||
{d:'health',v:['fetch']}]},
|
||||
{g:'本地工具(免登录)',open:true,domains:[
|
||||
{d:'snippet',v:['create','list','view','search','update','delete','export']},
|
||||
{d:'auth',v:['login','status','logout'],raw:true}]},
|
||||
];
|
||||
const LOCAL_DOMAINS=new Set(['snippet','auth','config','version','doctor']);
|
||||
|
||||
// 需要额外参数表单的动词(flag 已核对 CLI --help)
|
||||
const FORM={
|
||||
'search +issues':[{f:'-k',k:'keyword',req:true,ph:'如 good first issue'},{f:'-c',k:'category',sel:['opened','closed','all'],def:'opened'}],
|
||||
'search +repos':[{f:'-k',k:'keyword',req:true,ph:'如 gitlink'}],
|
||||
'search +users':[{f:'-k',k:'keyword',req:true,ph:'如用户名'}],
|
||||
'issue +list':[{f:'-s',k:'state',sel:['open','closed','all'],def:'open'},{f:'-k',k:'keyword',ph:'可选关键词'}],
|
||||
'issue +view':[{f:'-n',k:'number',req:true,ph:'Issue 编号(URL 里)'}],
|
||||
'pr +view':[{f:'-i',k:'id',req:true,ph:'PR 编号'}],
|
||||
'file +get':[{f:'--path',k:'path',req:true,ph:'如 LICENSE / README.md'}],
|
||||
'repo +tree':[{f:'--path',k:'path',ph:'目录路径,默认根'}],
|
||||
'snippet +create':[{f:'--title',k:'title',req:true},{f:'--language',k:'language',ph:'python/go'},{f:'--tags',k:'tags',ph:'逗号分隔'},{f:'--content',k:'content',req:true,area:true}],
|
||||
'snippet +view':[{f:'--id',k:'id',req:true,ph:'先 +list 取 id'}],
|
||||
'snippet +search':[{f:'--query',k:'query',req:true,ph:'搜索词'}],
|
||||
'snippet +delete':[{f:'--id',k:'id',req:true,ph:'先 +list 取 id'}],
|
||||
};
|
||||
|
||||
// ===== 渲染折叠浏览器 =====
|
||||
function renderDomains(q=''){
|
||||
const Q=q.trim().toLowerCase(); let html='';
|
||||
DOMAIN_GROUPS.forEach(grp=>{
|
||||
const doms=grp.domains.filter(dm=>!Q||dm.d.includes(Q)||dm.v.some(x=>x.includes(Q)));
|
||||
if(!doms.length) return;
|
||||
html+=`<details class="mb-1" ${grp.open&&!Q?'open':''}><summary class="cursor-pointer text-xs text-[var(--yellow)] py-1 select-none hover:text-[var(--acc)]"><span class="arr">▸</span> ${grp.g} <span class="text-[#6e7681]">(${doms.length})</span></summary><div class="pl-2 mt-1 space-y-1.5">`;
|
||||
doms.forEach(dm=>{
|
||||
const local=LOCAL_DOMAINS.has(dm.d);
|
||||
const tag=local?'<span class="text-[10px] text-[var(--green)]">本地</span>':'<span class="text-[10px] text-[#6e7681]">平台</span>';
|
||||
const verbs=dm.v.filter(x=>!Q||x.includes(Q)||dm.d.includes(Q)).map(v=>{
|
||||
const formTag=FORM[dm.d+' +'+v]?'<span class="text-[var(--acc)]">⚙</span>':'';
|
||||
return `<span class="verb" onclick="clickVerb('${dm.d}','+${v}')">${formTag}+${v}</span>`;
|
||||
}).join(' ');
|
||||
html+=`<div class="panel rounded p-2"><div class="mono text-xs text-[var(--acc)] mb-1">${dm.d} ${tag}</div><div class="flex flex-wrap gap-1">${verbs}</div></div>`;
|
||||
});
|
||||
html+=`</div></details>`;
|
||||
});
|
||||
document.getElementById('cmd-groups').innerHTML=html||'<p class="text-xs text-[#6e7681]">无匹配</p>';
|
||||
}
|
||||
renderDomains();
|
||||
document.getElementById('cmd-search').addEventListener('input',e=>renderDomains(e.target.value));
|
||||
|
||||
// ===== 参数构建 =====
|
||||
let currentVerb=null;
|
||||
function clickVerb(domain,verb){
|
||||
currentVerb={domain,verb,key:domain+' '+verb,local:LOCAL_DOMAINS.has(domain),form:FORM[domain+' '+verb]};
|
||||
renderBuilder();
|
||||
}
|
||||
function buildCmd(){
|
||||
const v=currentVerb; if(!v) return '';
|
||||
let parts=['gitlink-cli',v.domain,v.verb];
|
||||
if(v.form) v.form.forEach(p=>{
|
||||
const el=document.getElementById('pf-'+p.k); let val=el?el.value.trim():'';
|
||||
if(p.sel&&!val) val=p.def||'';
|
||||
if(val) parts.push(p.f,val);
|
||||
});
|
||||
if(!v.local) parts.push('--owner',getOwner(),'--repo',getRepo());
|
||||
return parts.join(' ');
|
||||
}
|
||||
function updateCmd(){ const c=buildCmd(); if(c) document.getElementById('builder-cmd').value=c; }
|
||||
function renderBuilder(){
|
||||
const v=currentVerb; const pe=document.getElementById('builder-params'); const hi=document.getElementById('builder-hint');
|
||||
if(!v){ pe.innerHTML=''; hi.textContent='点左侧动词开始'; document.getElementById('builder-cmd').value=''; return; }
|
||||
hi.innerHTML=`<code class="text-[var(--acc)]">${v.domain} ${v.verb}</code> · ${v.local?'本地命令':(v.form?'填参数后运行(自动带 owner/repo)':'平台命令,自动带 owner/repo')}`;
|
||||
if(v.form){
|
||||
pe.innerHTML=v.form.map(p=>{
|
||||
const req=p.req?'<span class="text-[var(--red)]">*</span>':'';
|
||||
const label=`<label class="text-xs text-[#8b949e]">${p.k}${req}${p.req?'<span class="text-[10px]"> 必填</span>':''}</label>`;
|
||||
if(p.sel) return `<div>${label}<select id="pf-${p.k}" class="w-full mt-0.5 bg-[#0d1117] border border-[var(--border)] rounded px-1 py-1 text-xs mono">${p.sel.map(s=>`<option ${s===p.def?'selected':''}>${s}</option>`).join('')}</select></div>`;
|
||||
if(p.area) return `<div class="col-span-2">${label}<textarea id="pf-${p.k}" rows="2" placeholder="${p.ph||''}" class="w-full mt-0.5 bg-[#0d1117] border border-[var(--border)] rounded px-2 py-1 text-xs mono"></textarea></div>`;
|
||||
return `<div>${label}<input id="pf-${p.k}" placeholder="${p.ph||''}" class="w-full mt-0.5 bg-[#0d1117] border border-[var(--border)] rounded px-2 py-1 text-xs mono"></div>`;
|
||||
}).join('');
|
||||
setTimeout(()=>v.form.forEach(p=>{const el=document.getElementById('pf-'+p.k);if(el){el.oninput=updateCmd;el.onchange=updateCmd;}}),0);
|
||||
} else { pe.innerHTML=''; }
|
||||
updateCmd();
|
||||
}
|
||||
function runBuilder(){ const c=document.getElementById('builder-cmd').value.trim(); if(c) runRaw(c); }
|
||||
|
||||
// ===== 终端 =====
|
||||
const out=document.getElementById('term-out');
|
||||
const input=document.getElementById('cmd-input');
|
||||
let history=[],hidx=0;
|
||||
function appendOut(text,cls='l-out'){const div=document.createElement('div');div.className='mono text-sm '+cls;div.textContent=text;out.appendChild(div);out.scrollTop=out.scrollHeight;}
|
||||
async function runRaw(cmd){
|
||||
if(!cmd||cmd.startsWith('#'))return;
|
||||
history.push(cmd);hidx=history.length;
|
||||
appendOut('$ '+cmd,'l-cmd');
|
||||
const m=document.createElement('div');m.className='mono text-sm l-muted';m.textContent='⏳ 运行中...';out.appendChild(m);out.scrollTop=out.scrollHeight;
|
||||
try{
|
||||
const r=await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd,token:getToken()})});
|
||||
const d=await r.json();m.remove();
|
||||
if(d.stdout) appendOut(d.stdout,'l-out');
|
||||
if(d.stderr) appendOut('[stderr] '+d.stderr,d.ok?'l-muted':'l-err');
|
||||
if(!d.ok){
|
||||
if(!d.stdout&&!d.stderr&&d.error) appendOut('❌ '+d.error,'l-err');
|
||||
if(d.code!=null) appendOut('[退出码 '+d.code+']','l-err');
|
||||
if(/缺少必需参数|required|missing/i.test(d.stderr||d.error||'')) appendOut('💡 该命令有必填参数,看上方「参数构建」填写','l-muted');
|
||||
if(d.needs_token&&!d.token_provided&&/401|登录|未授权|token/i.test(d.stderr||d.error||'')) appendOut('💡 此命令可能需要 token(顶栏填写)','l-muted');
|
||||
if(/timeout|handshake|refused|no such host|dial tcp/i.test(d.stderr||d.error||'')) appendOut('💡 网络连不上 GitLink(TLS/超时),稍后重试一次','l-muted');
|
||||
if(/接口数据异常|\[-1\]|数据异常/i.test(d.stderr||d.error||'')) appendOut('💡 GitLink 接口对该仓库返回异常(该仓库可能无此类数据 / CI 未启用)','l-muted');
|
||||
} else if(!d.stdout&&!d.stderr){ appendOut('(无输出)','l-muted'); }
|
||||
}catch(e){m.remove();appendOut('❌ 请求失败:'+e+'(后端 server.py 是否启动?)','l-err');}
|
||||
}
|
||||
input.addEventListener('keydown',e=>{
|
||||
if(e.key==='Enter'){const c=input.value.trim();input.value='';runRaw(c);}
|
||||
else if(e.key==='ArrowUp'){if(hidx>0){hidx--;input.value=history[hidx]||'';e.preventDefault();}}
|
||||
else if(e.key==='ArrowDown'){if(hidx<history.length){hidx++;input.value=history[hidx]||'';e.preventDefault();}}
|
||||
});
|
||||
|
||||
// ===== Skill 全集 =====
|
||||
const SKILLS=['auth','branch','ci','ci-health','code-review','collab-match','commit-quality','compare','competition-manager','compliance','contributor-insight','digest','file','gatekeeper','health','insight','issue','issue-tag','issue-triage','issueops','label','license-compliance','member','milestone','notification-digest','onboarding','org','pipeline','pipeline-guardian','pm','pr','pr-guard','release','release-auto','repo','research-fork-impact','research-graph','research-insight','research-progress','research-tracker','research-visual','scholar-profile','search','shared','snippet','stale-issue-manager','todo','user','webhook','webhook-sentinel','wiki','wiki-builder','workflow'];
|
||||
const NEW_SKILLS=new Set(['onboarding','auth','snippet','digest','todo','pr-guard','research-insight']);
|
||||
const SKILL_ICON={onboarding:'🚀',auth:'🔑',snippet:'📦',digest:'📰',todo:'📋','pr-guard':'🚪','research-insight':'🔬','code-review':'👁','commit-quality':'✨','health':'❤','release-auto':'🏷','workflow':'🔁','compliance':'✅','license-compliance':'📜','wiki-builder':'📚','pipeline-guardian':'🛡','webhook-sentinel':'📡'};
|
||||
function skillCat(n){if(NEW_SKILLS.has(n))return'new';if(/research|scholar|contributor-insight|insight|fork-impact|tracker/.test(n))return'research';if(/code-review|commit-quality|gatekeeper|pr-guard|compliance|license-compliance/.test(n))return'quality';return'other';}
|
||||
function skillEmoji(n){return SKILL_ICON[n]||(/research|scholar/.test(n)?'🔬':/wiki/.test(n)?'📚':/pipeline|ci|webhook/.test(n)?'🛠':'📁');}
|
||||
let SKILL_FILTER='all';
|
||||
function filterSkill(f,btn){SKILL_FILTER=f;document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');renderSkills();}
|
||||
function renderSkills(){
|
||||
const list=SKILLS.filter(n=>SKILL_FILTER==='all'?true:(SKILL_FILTER==='new'?NEW_SKILLS.has(n):skillCat(n)===SKILL_FILTER));
|
||||
document.getElementById('skill-wall').innerHTML=list.map(n=>{
|
||||
const tag=NEW_SKILLS.has(n)?'<span class="text-[10px] text-[var(--green)]">✨新增</span>':(skillCat(n)==='research'?'<span class="text-[10px] text-[var(--acc)]">科研</span>':skillCat(n)==='quality'?'<span class="text-[10px] text-[var(--yellow)]">质量</span>':'');
|
||||
return `<div class="skill-card panel rounded-lg p-3 cursor-pointer" onclick="openSkill('${n}')"><div class="text-xl mb-1">${skillEmoji(n)}</div><div class="text-white text-sm font-semibold mono">gitlink-${n}</div><div class="mt-1">${tag}</div><div class="text-[10px] text-[var(--green)] mt-2">📖 SKILL.md →</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
renderSkills();
|
||||
|
||||
// ===== pr-guard =====
|
||||
const STEPS=[{n:'① 采集',d:'pr +diff/+files',c:'#8b949e'},{n:'② AI Review',d:'分级找问题',c:'#d29922'},{n:'③ CI 检查',d:'ci +builds',c:'#58a6ff'},{n:'④ 汇总评论',d:'api reviews',c:'#a371f7'},{n:'⑤ 质量判定',d:'门禁规则',c:'#3fb950'}];
|
||||
document.getElementById('pr-steps').innerHTML=STEPS.map((s,i)=>`<div class="step panel rounded p-3 text-center flex-1 min-w-[120px]" id="step-${i}"><div class="text-sm font-semibold text-white">${s.n}</div><div class="text-xs mono mt-1" style="color:${s.c}">${s.d}</div></div>${i<STEPS.length-1?'<span class="text-[#6e7681]">→</span>':''}`).join('');
|
||||
function runPipeline(){
|
||||
document.getElementById('pr-verdict').textContent='';STEPS.forEach((_,i)=>document.getElementById('step-'+i).classList.remove('active'));
|
||||
let i=0;const tick=setInterval(()=>{if(i>0)document.getElementById('step-'+(i-1)).classList.remove('active');if(i>=STEPS.length){clearInterval(tick);document.getElementById('pr-verdict').innerHTML='<span style="color:var(--green)">✅ 质量判定:通过</span> | 0 Critical + CI success → 建议合并';return;}document.getElementById('step-'+i).classList.add('active');i++;},600);
|
||||
}
|
||||
async function livePR(){
|
||||
runRaw(`pr +list --owner ${getOwner()} --repo ${getRepo()} --format json`);
|
||||
}
|
||||
|
||||
// ===== 雷达 + 协作图 =====
|
||||
let radarChart=new Chart(document.getElementById('radar'),{type:'radar',data:{labels:['🔁 可复现性','📈 活跃度','📑 引用价值','🤝 协作健康'],datasets:[{label:'示例(点「实拉分析」换真实数据)',data:[6,5,5,6],fill:true,backgroundColor:'rgba(88,166,255,0.18)',borderColor:'#58a6ff',pointBackgroundColor:'#58a6ff'}]},options:{plugins:{legend:{labels:{color:'#c9d1d9',font:{size:11}}}},scales:{r:{min:0,max:10,ticks:{color:'#6e7681',backdropColor:'transparent',stepSize:2},grid:{color:'#30363d'},pointLabels:{color:'#c9d1d9',font:{size:12}},angleLines:{color:'#30363d'}}}}});
|
||||
function drawCollab(contribs,repoName){
|
||||
const svg=document.getElementById('collab-svg');svg.innerHTML='';const cx=180,cy=130,R=95;
|
||||
svg.innerHTML+=`<circle cx="${cx}" cy="${cy}" r="24" fill="#1f6feb"/><text x="${cx}" y="${cy+3}" text-anchor="middle" fill="#fff" font-size="9">${(repoName||'repo').slice(0,8)}</text>`;
|
||||
const top=(contribs||[]).slice(0,6);if(!top.length){svg.innerHTML+='<text x="180" y="250" text-anchor="middle" fill="#6e7681" font-size="10">(点「实拉分析」用真实贡献者重绘)</text>';return;}
|
||||
const max=top[0].contributions||1;
|
||||
top.forEach((c,i)=>{const a=(-Math.PI/2)+i*(2*Math.PI/top.length),x=cx+Math.cos(a)*R,y=cy+Math.sin(a)*R,rad=Math.max(8,18*(c.contributions/max)),core=i<2;
|
||||
svg.innerHTML+=`<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="${core?'#3fb950':'#30363d'}" stroke-width="${core?2:1}" stroke-dasharray="${core?'':'4'}"/>`;
|
||||
svg.innerHTML+=`<circle cx="${x}" cy="${y}" r="${rad}" fill="${core?'#238636':'#6e7681'}"/><text x="${x}" y="${y+3}" text-anchor="middle" fill="#fff" font-size="9">${(c.name||'?').slice(0,8)}</text><text x="${x}" y="${y+rad+12}" text-anchor="middle" fill="#8b949e" font-size="8">${c.perc||''}</text>`;});
|
||||
}
|
||||
drawCollab(null);
|
||||
async function analyze(){
|
||||
document.getElementById('analyze-status').textContent='⏳ 采集中...';
|
||||
try{
|
||||
const r=await fetch('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({owner:getOwner(),repo:getRepo(),token:getToken()})});
|
||||
const d=await r.json();if(!d.ok){document.getElementById('analyze-status').textContent='❌ '+d.error;return;}
|
||||
const s=d.scores;radarChart.data.datasets[0].label=`${d.owner}/${d.repo}${d.is_fork?'(⚠️ fork of '+d.fork_from+')':''}`;
|
||||
radarChart.data.datasets[0].data=[s.repro,s.activity,s.citation,s.collab];radarChart.update();
|
||||
document.getElementById('analyze-status').innerHTML=`✅ ${d.contributor_count} 贡献者 · ${d.release_count} release · 协议 ${d.license}`;
|
||||
document.getElementById('repo-meta').innerHTML=`${d.is_fork?'⚠️ <b style="color:var(--yellow)">是 fork</b>(引用应指 upstream '+d.fork_from+')':'✅ 独立仓库'} · 巴士因子 <b style="color:${d.bus_risk==='低'?'var(--green)':d.bus_risk==='中'?'var(--yellow)':'var(--red)'}">${d.bus_factor}%(${d.bus_risk}风险)</b>`;
|
||||
document.getElementById('repro-detail').innerHTML='可复现性 '+s.repro+'/'+d.repro_max+':'+d.repro_detail.map(x=>x[1]?'✅'+x[0]:'❌'+x[0]).join(' · ');
|
||||
drawCollab(d.contributors,d.name);
|
||||
}catch(e){document.getElementById('analyze-status').textContent='❌ '+e;}
|
||||
}
|
||||
|
||||
// 初始提示
|
||||
appendOut('💡 左侧点分类展开 → 点动词(带 ⚙ 的有参数表单)→ 右侧填参 → ▶运行。','l-muted');
|
||||
appendOut(' 本地命令(snippet/auth)免 token;公开仓库的 repo/issue 等其实不填 token 也能跑。','l-muted');
|
||||
|
||||
// ===== Skill 详情模态框 =====
|
||||
async function openSkill(name){
|
||||
const modal=document.getElementById('skill-modal');
|
||||
document.getElementById('skill-modal-title').textContent='📖 gitlink-'+name;
|
||||
const body=document.getElementById('skill-modal-body');body.innerHTML='<p class="text-[#8b949e]">⏳ 加载 SKILL.md...</p>';modal.classList.remove('hidden');
|
||||
try{const r=await fetch('/api/skill?name='+encodeURIComponent(name));const d=await r.json();if(!d.ok){body.innerHTML='<p class="l-err">❌ '+d.error+'</p>';return;}body.innerHTML=marked.parse(d.content);}catch(e){body.innerHTML='<p class="l-err">❌ '+e+'</p>';}
|
||||
}
|
||||
function closeSkill(){document.getElementById('skill-modal').classList.add('hidden');}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeSkill();});
|
||||
</script>
|
||||
|
||||
<div id="skill-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" onclick="closeSkill()" style="background:rgba(0,0,0,.75)">
|
||||
<div class="panel rounded-lg max-w-4xl w-full max-h-[88vh] flex flex-col" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<h3 id="skill-modal-title" class="text-white font-bold text-lg"></h3>
|
||||
<button onclick="closeSkill()" class="text-[#8b949e] hover:text-white text-3xl leading-none">×</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto text-sm" id="skill-modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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已停止")
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# gitlink-cli 开发指南
|
||||
|
||||
## 环境准备
|
||||
|
||||
### 前置条件
|
||||
- Go 1.22+(构建 CLI)
|
||||
- Node.js 14+(npm 安装包,可选)
|
||||
- Git
|
||||
|
||||
### 克隆与构建
|
||||
|
||||
```bash
|
||||
git clone https://gitlink.org.cn/Gitlink/gitlink-cli.git
|
||||
cd gitlink-cli
|
||||
make build # 构建二进制
|
||||
make install # 安装到 $GOPATH/bin
|
||||
make test # 运行测试
|
||||
make lint # 代码检查
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
cmd/ # Cobra 命令定义
|
||||
root.go # 根命令 + 全局 flags
|
||||
auth/auth.go # 认证命令
|
||||
api/api.go # Raw API 命令
|
||||
config/config.go # 配置命令
|
||||
cmdutil/ # 全局工具
|
||||
internal/ # 内部包
|
||||
auth/ # 登录、Token 存储
|
||||
client/ # HTTP 客户端 + 分页
|
||||
config/ # 配置文件管理
|
||||
context/ # git remote 解析
|
||||
output/ # 输出格式化
|
||||
shortcuts/ # Shortcut 实现
|
||||
common/ # 框架(types, runner, testutil)
|
||||
_template/ # 开发模板
|
||||
register.go # 注册入口
|
||||
issue/ # Issue shortcuts(参考实现)
|
||||
skills/ # AI Agent Skills
|
||||
```
|
||||
|
||||
## 新增 Shortcut 指南
|
||||
|
||||
### 1. 创建域目录
|
||||
|
||||
```bash
|
||||
mkdir -p shortcuts/mydomain
|
||||
```
|
||||
|
||||
### 2. 实现 Shortcuts
|
||||
|
||||
参考 `shortcuts/_template/template.go` 或 `shortcuts/issue/issue.go`。
|
||||
|
||||
每个域需要实现 `Shortcuts()` 函数,返回 `[]*common.Shortcut` 列表:
|
||||
|
||||
```go
|
||||
package mydomain
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List resources",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/resources", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 注册域
|
||||
|
||||
在 `shortcuts/register.go` 中添加:
|
||||
|
||||
```go
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/mydomain"
|
||||
|
||||
// 在 RegisterShortcuts 函数中:
|
||||
common.MountShortcut(rootCmd, "mydomain", mydomain.Shortcuts())
|
||||
```
|
||||
|
||||
### 4. 编写测试
|
||||
|
||||
参考 `shortcuts/issue/issue_test.go`,使用 `common.NewTestServer` 和 `common.NewTestContext`:
|
||||
|
||||
```go
|
||||
package mydomain
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMyDomainList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/resources.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"resources": []interface{}{},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected: %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(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API 约定
|
||||
|
||||
### v1 API 路径
|
||||
Issue 相关操作使用 v1 路径:`/v1/{owner}/{repo}/issues`
|
||||
其他操作使用:`/{owner}/{repo}/...`
|
||||
|
||||
### HTTP 方法映射
|
||||
| 操作 | 方法 |
|
||||
|------|------|
|
||||
| 列表 | GET |
|
||||
| 查看 | GET |
|
||||
| 创建 | POST |
|
||||
| 更新 | PATCH/PUT |
|
||||
| 删除 | DELETE |
|
||||
|
||||
### 通用参数
|
||||
- `--owner` / `--repo`:自动从 git remote 解析
|
||||
- `--format`:输出格式(json/table/yaml)
|
||||
- `--page` / `--limit`:分页
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make test # 运行所有测试
|
||||
make test-cover # 运行测试并生成覆盖率报告
|
||||
go test -v ./shortcuts/mydomain/ # 测试单个域
|
||||
```
|
||||
|
||||
## 提交 PR 前检查清单
|
||||
|
||||
- [ ] `make test` 通过
|
||||
- [ ] `make lint` 通过(或 `go vet ./...`)
|
||||
- [ ] 新 Shortcut 已注册到 `register.go`
|
||||
- [ ] 包含单元测试
|
||||
- [ ] 帮助文档(Description、Flags Usage)已更新
|
||||
|
|
@ -0,0 +1,647 @@
|
|||
# PR Diff 功能实现方案
|
||||
|
||||
## 一、问题定义
|
||||
|
||||
### 现状
|
||||
|
||||
`pr +diff` 和 `pr +files` 实现完全相同——都调用 `/pulls/{index}/files.json`(简版文件列表 API),返回文件名和增删行数统计,**不包含任何差异内容**。
|
||||
|
||||
```go
|
||||
// shortcuts/pr/pr.go — 当前 +diff 实现(与 +files 一模一样)
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a pull request",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// ...
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 用户期望
|
||||
|
||||
`pr +diff` 应输出类似 `git diff` 的内容:每个文件的增删行和具体变更,而不仅仅是文件列表。
|
||||
|
||||
---
|
||||
|
||||
## 二、GitLink API 调研
|
||||
|
||||
经过对 `gitlink_api_reference.md` 的全面分析,GitLink 提供了 **3 个** 与 diff 相关的 API 端点:
|
||||
|
||||
### 端点 1:PR 文件列表(简版)— 当前 `+files` 使用的
|
||||
|
||||
```
|
||||
GET /api/v1/{owner}/{repo}/pulls/{index}/files.json
|
||||
```
|
||||
|
||||
- **返回内容**:文件名、增删行数统计、SHA(**无 diff 内容**)
|
||||
- **适用场景**:文件概览列表,适合 `+files` 命令
|
||||
|
||||
### 端点 2:PR 版本列表
|
||||
|
||||
```
|
||||
GET /api/v1/{owner}/{repo}/pulls/{index}/versions.json
|
||||
```
|
||||
|
||||
- **返回内容**:PR 的每次推送产生一个 version,包含 `id`、`add_line_num`、`del_line_num`、`commits_count`、`files_count`、各 commit SHA
|
||||
- **关键作用**:获取最新 version 的 `id`,用于调用端点 3
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_count": 2,
|
||||
"versions": [
|
||||
{
|
||||
"id": 38,
|
||||
"add_line_num": 1,
|
||||
"del_line_num": 0,
|
||||
"files_count": 1,
|
||||
"base_commit_sha": "96dc82d...",
|
||||
"head_commit_sha": "37d52b...",
|
||||
"start_commit_sha": "96dc82d..."
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"add_line_num": 5,
|
||||
"del_line_num": 3,
|
||||
"files_count": 2,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 端点 3:PR 版本 Diff(核心端点)— 需要使用
|
||||
|
||||
```
|
||||
GET /api/v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff.json
|
||||
```
|
||||
|
||||
- **返回内容**:完整的 diff 信息,包含每个文件的 `sections` → `lines`,每行有 `type`(1=新增/2=修改/3=删除/4=统计头)和 `content`
|
||||
- **可选参数**:`filepath` 查询参数可只查看单个文件的 diff
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_nums": 1,
|
||||
"total_addition": 1,
|
||||
"total_deletion": 0,
|
||||
"files": [
|
||||
{
|
||||
"name": "main.go",
|
||||
"oldname": "main.go",
|
||||
"addition": 1,
|
||||
"deletion": 0,
|
||||
"type": 1,
|
||||
"is_created": true,
|
||||
"sections": [
|
||||
{
|
||||
"file_name": "main.go",
|
||||
"lines": [
|
||||
{
|
||||
"type": 4,
|
||||
"content": "@@ -0,0 +1 @@"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"content": "+fmt.Println(\"hello\")"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 端点 4(备选):Compare Diff
|
||||
|
||||
```
|
||||
GET /api/v1/{owner}/{repo}/compare.json
|
||||
GET /api/{owner}/{repo}/compare/{head}...{base}.json
|
||||
```
|
||||
|
||||
- **返回内容**:两个分支间的完整 diff(结构与端点 3 类似)
|
||||
- **适用场景**:本地分支比较,不依赖 PR 编号
|
||||
|
||||
---
|
||||
|
||||
## 三、实现方案
|
||||
|
||||
### 方案选择:基于 PR Versions API 的两步调用
|
||||
|
||||
**调用流程**:
|
||||
|
||||
```
|
||||
用户执行: gitlink-cli pr +diff --id 42
|
||||
│
|
||||
▼
|
||||
Step 1: GET /{owner}/{repo}/pulls/42/versions.json
|
||||
│ 获取版本列表,取最新版本的 id
|
||||
│
|
||||
▼
|
||||
Step 2: GET /{owner}/{repo}/pulls/42/versions/{version_id}/diff.json
|
||||
│ 获取完整 diff 数据
|
||||
│
|
||||
▼
|
||||
格式化输出(unified diff 风格 / JSON / 统计摘要)
|
||||
```
|
||||
|
||||
### 3.1 核心实现:修改 `shortcuts/pr/pr.go`
|
||||
|
||||
#### 3.1.1 新增 `+diff` Shortcut
|
||||
|
||||
替换现有的 `+diff` 命令实现:
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a pull request",
|
||||
Flags: []common.Flag{
|
||||
{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 (no line-level detail)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
|
||||
// Step 1: 获取最新 version ID
|
||||
versionID, err := getLatestVersionID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 版本失败: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: 获取 diff 数据
|
||||
diffPath := fmt.Sprintf("/v1%s/pulls/%s/versions/%s/diff",
|
||||
ctx.RepoPath(), id, versionID)
|
||||
q := url.Values{}
|
||||
if f := ctx.Arg("file"); f != "" {
|
||||
q.Set("filepath", f)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", diffPath, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: 格式化输出
|
||||
if ctx.Arg("stat") == "true" {
|
||||
return ctx.Output(formatDiffStat(env))
|
||||
}
|
||||
return ctx.Output(formatDiffUnified(env))
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
#### 3.1.2 辅助函数:获取最新 Version ID
|
||||
|
||||
```go
|
||||
// getLatestVersionID 调用 versions API 并返回最新版本的 ID。
|
||||
// GitLink 返回的 versions 数组按时间倒序排列,第一个即最新。
|
||||
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
|
||||
}
|
||||
|
||||
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("no versions found for 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
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.3 格式化:统一 Diff 风格输出
|
||||
|
||||
将 GitLink API 返回的 `sections/lines` 结构转为类似 `git diff` 的文本格式:
|
||||
|
||||
```go
|
||||
// formatDiffUnified 将 API diff 数据转为 unified diff 风格的 Envelope。
|
||||
// 在 JSON 模式下直接输出原始结构;在 table 模式下输出 diff 文本。
|
||||
func formatDiffUnified(env *output.Envelope) *output.Envelope {
|
||||
// 直接返回原始数据,让 formatter 处理
|
||||
// JSON/YAML 模式下输出完整结构化数据
|
||||
// Table 模式下由自定义渲染器处理
|
||||
return env
|
||||
}
|
||||
|
||||
// formatDiffStat 提取增删统计摘要。
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 增强输出格式化:支持 Diff 文本渲染
|
||||
|
||||
在 `internal/output/formatter.go` 中新增 diff 格式化能力:
|
||||
|
||||
```go
|
||||
// printDiffTable 渲染 diff 内容为可读的文本格式。
|
||||
// 仅当 Data 中包含 diff 结构(有 files[].sections)时生效。
|
||||
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)
|
||||
}
|
||||
|
||||
// 统计头部
|
||||
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)
|
||||
|
||||
// 文件头
|
||||
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))
|
||||
|
||||
// 渲染每一行
|
||||
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, "\033[32m%s\033[0m\n", content)
|
||||
case 3: // deletion
|
||||
fmt.Fprintf(w, "\033[31m%s\033[0m\n", content)
|
||||
default: // context line
|
||||
fmt.Fprintf(w, "%s\n", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 修改 `printTable` 路由以支持 diff 格式
|
||||
|
||||
在 `internal/output/formatter.go` 的 `printTable` 函数中增加 diff 检测逻辑:
|
||||
|
||||
```go
|
||||
func printTable(w io.Writer, envelope *Envelope) error {
|
||||
// ... 现有错误和空数据处理 ...
|
||||
|
||||
// 检测是否为 diff 数据(包含 files[].sections)
|
||||
if isDiffData(envelope.Data) {
|
||||
return printDiffTable(w, envelope)
|
||||
}
|
||||
|
||||
// ... 现有 slice/map 处理逻辑 ...
|
||||
}
|
||||
|
||||
// isDiffData 检测 Envelope.Data 是否为 PR diff 结构。
|
||||
func isDiffData(data interface{}) bool {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// diff 数据的特征:有 file_nums 和 files 字段
|
||||
_, hasFileNums := m["file_nums"]
|
||||
_, hasFiles := m["files"]
|
||||
_, hasTotalAdd := m["total_addition"]
|
||||
return hasFileNums && hasFiles && hasTotalAdd
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 完整的文件变更清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `shortcuts/pr/pr.go` | **修改** | 替换 `+diff` 实现,新增 `getLatestVersionID`、`formatDiffUnified`、`formatDiffStat` 函数 |
|
||||
| `internal/output/formatter.go` | **修改** | 新增 `isDiffData`、`printDiffTable` 函数,修改 `printTable` 路由 |
|
||||
| `shortcuts/pr/pr_test.go` | **修改** | 新增 `+diff` 功能测试用例 |
|
||||
| `skills/gitlink-pr/SKILL.md` | **修改** | 更新 `+diff` 命令文档,说明新增的 `--file` 和 `--stat` 参数 |
|
||||
| `skills/gitlink-pr/references/gitlink-pr-files.md` | **修改** | 补充 diff 与 files 的区别说明 |
|
||||
|
||||
---
|
||||
|
||||
## 四、测试方案
|
||||
|
||||
### 4.1 单元测试
|
||||
|
||||
在 `shortcuts/pr/pr_test.go` 中新增以下测试用例:
|
||||
|
||||
| 测试用例 | 验证内容 |
|
||||
|----------|----------|
|
||||
| `TestPRDiffFetchesVersionThenDiff` | 完整两步调用:先请求 versions,再请求 diff,验证最终输出正确 |
|
||||
| `TestPRDiffWithFileFilter` | 验证 `--file` 参数正确传递 `filepath` 查询参数 |
|
||||
| `TestPRDiffStatMode` | 验证 `--stat` 模式只输出统计摘要,不输出逐行内容 |
|
||||
| `TestPRDiffFailsWhenNoVersions` | PR 无版本时返回友好错误信息 |
|
||||
| `TestPRDiffFailsWhenPRNotFound` | PR 不存在时(404)正确处理错误 |
|
||||
| `TestGetLatestVersionID` | 直接测试 `getLatestVersionID` 函数,验证取第一个版本 |
|
||||
| `TestFormatDiffStat` | 测试统计摘要格式化逻辑 |
|
||||
|
||||
测试 Mock Server 示例:
|
||||
|
||||
```go
|
||||
func TestPRDiffFetchesVersionThenDiff(t *testing.T) {
|
||||
var requestPaths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPaths = append(requestPaths, r.URL.Path)
|
||||
|
||||
switch {
|
||||
// Step 1: versions 请求
|
||||
case strings.Contains(r.URL.Path, "/versions.json") &&
|
||||
!strings.Contains(r.URL.Path, "/diff"):
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"versions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(38),
|
||||
"add_line_num": 3,
|
||||
"del_line_num": 1,
|
||||
"files_count": 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Step 2: diff 请求
|
||||
case strings.Contains(r.URL.Path, "/versions/38/diff"):
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"file_nums": float64(2),
|
||||
"total_addition": float64(3),
|
||||
"total_deletion": float64(1),
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "main.go",
|
||||
"addition": float64(2),
|
||||
"deletion": float64(1),
|
||||
"sections": []interface{}{
|
||||
map[string]interface{}{
|
||||
"lines": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": float64(4),
|
||||
"content": "@@ -10,3 +10,4 @@",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": float64(3),
|
||||
"content": "-old line",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": float64(2),
|
||||
"content": "+new line",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "diff", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("diff shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证两次请求都发生了
|
||||
if len(requestPaths) < 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d: %v", len(requestPaths), requestPaths)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 集成测试建议
|
||||
|
||||
由于需要真实 GitLink API,建议:
|
||||
|
||||
1. 在测试仓库上创建测试 PR(含多文件变更)
|
||||
2. 验证 `gitlink-cli pr +diff --id <N>` 输出与 Web UI 显示一致
|
||||
3. 验证 `--file` 过滤功能
|
||||
4. 验证 `--stat` 模式只输出统计
|
||||
|
||||
---
|
||||
|
||||
## 五、API 路径注意事项
|
||||
|
||||
当前项目 Client 的 `.json` 后缀自动追加逻辑([client.go:44-55](gitlink-cli/internal/client/client.go#L44-L55))会自动为路径添加 `.json`,因此代码中写路径时**不需要手动加 `.json`**:
|
||||
|
||||
```go
|
||||
// 正确 — Client 会自动追加 .json
|
||||
"/v1%s/pulls/%s/versions"
|
||||
"/v1%s/pulls/%s/versions/%s/diff"
|
||||
|
||||
// 错误 — 会导致双重后缀
|
||||
"/v1%s/pulls/%s/versions.json" // → versions.json.json
|
||||
"/v1%s/pulls/%s/versions/%s/diff.json" // → diff.json.json
|
||||
```
|
||||
|
||||
注意:现有 `+files` 使用的是无 `/v1` 前缀的路径 `/{owner}/{repo}/pulls/{index}/files`,而 versions 端点的文档路径为 `/api/v1/{owner}/{repo}/pulls/{index}/versions.json`。需要验证 Client 的 BaseURL 是否已包含 `/api` 前缀。查看 config 默认值:
|
||||
|
||||
```go
|
||||
// internal/config/config.go 中 BaseURL 默认值
|
||||
// 需要确认是否为 "https://www.gitlink.org.cn/api"
|
||||
// 如果是,则路径写为 "/v1/{owner}/{repo}/pulls/{index}/versions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、风险与降级策略
|
||||
|
||||
| 风险 | 影响 | 降级方案 |
|
||||
|------|------|----------|
|
||||
| GitLink versions API 不稳定或返回空 | 无法获取 diff | 保留旧 `+files` 行为作为 fallback,输出提示 "diff unavailable, showing file list only" |
|
||||
| 大型 PR 的 diff 数据量过大 | 响应慢、终端刷屏 | 默认只显示统计摘要(`--stat`),加 `--full` 参数才显示逐行内容 |
|
||||
| API 路径前缀与实际不匹配 | 请求 404 | 开发时先通过 `api` 命令验证路径:`gitlink-cli api GET /v1/owner/repo/pulls/1/versions` |
|
||||
| table 模式下 diff 文本格式化复杂 | 渲染异常 | table 模式 fallback 到 JSON 输出 |
|
||||
|
||||
---
|
||||
|
||||
## 七、实现步骤(Checklist)
|
||||
|
||||
- [ ] **Step 1**:验证 API 端点可达性
|
||||
```bash
|
||||
gitlink-cli api GET /v1/{owner}/{repo}/pulls/1/versions
|
||||
gitlink-cli api GET /v1/{owner}/{repo}/pulls/1/versions/{vid}/diff
|
||||
```
|
||||
确认 BaseURL + 路径组合正确
|
||||
|
||||
- [ ] **Step 2**:修改 `shortcuts/pr/pr.go`
|
||||
- 替换 `+diff` 的 `Run` 函数为两步调用逻辑
|
||||
- 新增 `getLatestVersionID` 函数
|
||||
- 新增 `formatDiffUnified`、`formatDiffStat` 函数
|
||||
- 为 `+diff` 添加 `--file` 和 `--stat` 参数
|
||||
|
||||
- [ ] **Step 3**:修改 `internal/output/formatter.go`
|
||||
- 新增 `isDiffData` 检测函数
|
||||
- 新增 `printDiffTable` 渲染函数
|
||||
- 修改 `printTable` 路由增加 diff 分支
|
||||
|
||||
- [ ] **Step 4**:编写测试
|
||||
- `shortcuts/pr/pr_test.go` 新增 5-7 个测试用例
|
||||
- 使用 httptest.Server mock versions 和 diff 端点
|
||||
|
||||
- [ ] **Step 5**:更新 Skill 文档
|
||||
- 更新 `skills/gitlink-pr/SKILL.md`
|
||||
- 更新 `skills/gitlink-pr/references/gitlink-pr-files.md`
|
||||
|
||||
- [ ] **Step 6**:手动集成测试
|
||||
- 在真实 GitLink 仓库上验证完整流程
|
||||
|
||||
---
|
||||
|
||||
## 八、预期效果
|
||||
|
||||
### 命令使用示例
|
||||
|
||||
```bash
|
||||
# 查看完整 diff(默认)
|
||||
gitlink-cli pr +diff --id 42
|
||||
|
||||
# 只查看某个文件的 diff
|
||||
gitlink-cli pr +diff --id 42 --file "main.go"
|
||||
|
||||
# 只看统计摘要(适合大型 PR)
|
||||
gitlink-cli pr +diff --id 42 --stat
|
||||
|
||||
# JSON 格式输出(适合脚本处理)
|
||||
gitlink-cli pr +diff --id 42 --format json
|
||||
```
|
||||
|
||||
### 输出示例(table 模式)
|
||||
|
||||
```
|
||||
2 files changed, 15 insertions(+), 3 deletions(-)
|
||||
|
||||
diff --git a/main.go b/main.go
|
||||
new file
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ +5 -2 @@
|
||||
@@ -10,3 +10,4 @@
|
||||
-old implementation
|
||||
+new implementation
|
||||
+another new line
|
||||
|
||||
diff --git a/README.md b/README.md
|
||||
--- a/README.md
|
||||
+++ b/README.md
|
||||
@@ +3 -1 @@
|
||||
@@ -5,7 +5,6 @@
|
||||
-removed section
|
||||
```
|
||||
|
||||
### 输出示例(--stat 模式)
|
||||
|
||||
```
|
||||
FILE ADDITION DELETION TYPE
|
||||
--- -------- -------- ----
|
||||
main.go 15 2 added
|
||||
README.md 0 1 modified
|
||||
```
|
||||
|
||||
### 输出示例(JSON 模式)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"file_nums": 2,
|
||||
"total_addition": 15,
|
||||
"total_deletion": 3,
|
||||
"files": [
|
||||
{
|
||||
"name": "main.go",
|
||||
"addition": 15,
|
||||
"deletion": 2,
|
||||
"sections": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Shortcut 缺口跟踪
|
||||
|
||||
记录 GitLink API 中有端点但尚无 Shortcut 的缺口,按领域组织。
|
||||
|
||||
## issue
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `GET /v1/:owner/:repo/issues/:number/journals` | `issue +journals` | ✅ 已实现 (2026-06-02) |
|
||||
|
||||
## repo
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `GET /:owner/:repo/readme` | `repo +readme` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/contributors` | `repo +contributors` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/languages` | `repo +languages` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/commits` | `repo +commits` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/tags` | `repo +tags` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/sub_entries?filepath=...&ref=...` | `repo +raw` | ✅ 已实现 (2026-06-02) |
|
||||
|
||||
> 2026-06-03 实测:`GET /:owner/:repo/raw/:ref/:path` 端点对 API Token 认证返回 403,已替换为 `sub_entries` 端点,从返回的 `entries.content` 字段提取文件内容。
|
||||
|
||||
## pr
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `POST /:owner/:repo/pulls/check_can_merge` | `pr +check-merge` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /:owner/:repo/pulls/get_branches` | `pr +branches` | ✅ 已实现 (2026-06-02) |
|
||||
|
||||
## ci
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `GET /:owner/:repo/ci_authorize` | `ci +authorize` | ✅ 已实现 (2026-06-02) |
|
||||
| `POST /v1/:owner/:repo/actions/enable` | `ci +enable` | ✅ 已实现 (2026-06-02) |
|
||||
| `POST /v1/:owner/:repo/actions/disable` | `ci +disable` | ✅ 已实现 (2026-06-02) |
|
||||
| `POST /:owner/:repo/activate` | — | ❌ 端点路由存在但后端未实现(返回-1),用 actions/enable 替代 |
|
||||
| `DELETE /:owner/:repo/deactivate` | — | ❌ 端点路由存在但后端未实现(返回-1),用 actions/disable 替代 |
|
||||
|
||||
## org
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `GET /organizations/:id/teams` | `org +teams` | ✅ 已实现 (2026-06-02) |
|
||||
| `POST /organizations/:id/teams` | `org +create-team` | ✅ 已实现 (2026-06-02) |
|
||||
| `DELETE /organizations/:id/organization_users/:uid` | `org +remove-member` | ✅ 已实现 (2026-06-02) |
|
||||
|
||||
> 2026-06-02 通过 `gitlink-cli api` 对组织 150530 实测:
|
||||
> - GET teams 返回 teams 数组,支持 page/limit 分页
|
||||
> - POST teams 返回 403(当前用户非 owner),端点存在
|
||||
> - DELETE organization_users 对无效 ID 返回"组织成员不存在",端点存在
|
||||
|
||||
## user
|
||||
|
||||
| 端点 | 计划 Shortcut | 状态 |
|
||||
|------|--------------|------|
|
||||
| `GET /users/:user_id/headmaps` | `user +headmaps` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /users/:user_id/statistics/activity` | `user +stats-activity` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /users/:user_id/statistics/develop` | `user +stats-develop` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /users/:user_id/statistics/role` | `user +stats-role` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /users/:user_id/statistics/major` | `user +stats-major` | ✅ 已实现 (2026-06-02) |
|
||||
| `GET /users/:user_id/project_trends` | `user +trends` | ✅ 已实现 (2026-06-02) |
|
||||
|
||||
> 2026-06-02 通过 `gitlink-cli api` 对用户 baoerjun 实测:
|
||||
> - headmaps 返回 headmaps 数组(不支持分页)
|
||||
> - statistics 拆分为 4 个子端点(activity/develop/role/major),均可用
|
||||
> - project_trends 返回动态列表,支持 page/limit 分页(实测 total_count=69)
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 领域 | 已实现 | 端点已验证待实现 | 端点不存在/后端未实现 |
|
||||
|------|--------|----------------|---------------------|
|
||||
| issue | 1 (`+journals`) | 0 | 0 |
|
||||
| repo | 6 | 0 | 0 |
|
||||
| pr | 2 (`+check-merge` `+branches`) | 0 | 0 |
|
||||
| ci | 3 (`+authorize` `+enable` `+disable`) | 0 | 2 (`activate`/`deactivate`) |
|
||||
| org | 3 (`+teams` `+create-team` `+remove-member`) | 0 | 0 |
|
||||
| user | 6 (`+headmaps` `+stats-*` 5个 `+trends`) | 0 | 0 |
|
||||
| **合计** | **21** | **0** | **2** |
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
# Wiki Shortcuts 实现文档
|
||||
|
||||
> 作者:人员 A | 日期:2026-06-04
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
为 GitLink CLI 新增了 **Wiki 管理** 功能模块,共 **9 个** Shortcut 命令,覆盖 Wiki 页面和目录的完整管理操作。
|
||||
|
||||
### 命令列表
|
||||
|
||||
| 命令 | 说明 | 方法 | 真实 API 验证 |
|
||||
|------|------|------|--------------|
|
||||
| `wiki +list` | 列出所有 Wiki 页面 | GET | ✅ |
|
||||
| `wiki +view` | 查看 Wiki 页面内容 | GET | ✅ |
|
||||
| `wiki +create` | 创建 Wiki 页面(可指定目录) | POST | ✅ |
|
||||
| `wiki +update` | 更新 Wiki 页面内容 | PUT | ✅ |
|
||||
| `wiki +delete` | 删除 Wiki 页面 + 清理 Sidebar | DELETE | ✅ |
|
||||
| `wiki +mkdir` | 新建目录(可建子目录) | PUT (Sidebar) | ✅ |
|
||||
| `wiki +rmdir` | 删除目录 | PUT (Sidebar) | ✅ |
|
||||
| `wiki +rename` | 重命名页面 | GET+POST+DELETE+PUT | ✅ |
|
||||
| `wiki +renamedir` | 重命名目录 | PUT (Sidebar) | ✅ |
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 短选项 | 说明 | 必填 | 适用命令 |
|
||||
|------|--------|------|------|----------|
|
||||
| `--owner` | | 仓库拥有者 | 是* | 所有 |
|
||||
| `--repo` | | 仓库名称 | 是* | 所有 |
|
||||
| `--name` | `-n` | 页面/目录名称 | 是 | view, create, update, delete, mkdir, rmdir, rename, renamedir |
|
||||
| `--content` | `-c` | 页面内容(自动 base64 编码) | 是 | create, update |
|
||||
| `--message` | `-m` | 提交信息 | 否 | create, update |
|
||||
| `--dir` | `-d` | 父目录名(将页面创建到该目录下) | 否 | create |
|
||||
| `--parent` | `-p` | 父目录名(创建子目录) | 否 | mkdir |
|
||||
| `--new-name` | `-N` | 新名称 | 是 | rename, renamedir |
|
||||
|
||||
> *在 git 仓库目录下执行时,`--owner` 和 `--repo` 会自动从 git remote 解析。
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### API 网关差异
|
||||
|
||||
Wiki API 与其他 Shortcut 使用的标准 API 路径完全不同:
|
||||
|
||||
| 类型 | 域名 | 路径前缀 | 格式 |
|
||||
|------|------|----------|------|
|
||||
| 标准 API | `www.gitlink.org.cn/api` | `/v1/{owner}/{repo}/...` | URL 带 `.json` 后缀,参数用 query |
|
||||
| **Wiki API** | `gateway.gitlink.org.cn/api` | `/wiki/open/...` | URL 无后缀,参数用 JSON body 或 query |
|
||||
|
||||
### 关键发现:Wiki API 真实地址
|
||||
|
||||
GitLink 官方 API 文档(Swagger)中**没有记录** Wiki 相关接口。真实的 Wiki API 地址是通过浏览器 F12 抓包发现的:
|
||||
|
||||
> 在浏览器中打开 GitLink 项目的 Wiki 页面,按 F12 打开开发者工具 → Network 标签 → 筛选 Fetch/XHR 请求 → 观察 Wiki 操作发出的网络请求。
|
||||
|
||||
发现 Wiki API 位于 `gateway.gitlink.org.cn/api/wiki/open/` 路径下,而非文档中的 `www.gitlink.org.cn/api/wiki/`。
|
||||
|
||||
### Sidebar 目录机制
|
||||
|
||||
GitLink Wiki 的目录结构**完全由 `_Sidebar` 页面控制**,没有单独的目录 API。Sidebar 内容格式:
|
||||
|
||||
```
|
||||
- 目录A
|
||||
[[页面1]]
|
||||
- 子目录
|
||||
[[页面2]]
|
||||
[[独立页面]]
|
||||
- 目录B
|
||||
[[页面3]]
|
||||
```
|
||||
|
||||
规则:
|
||||
- `- 目录名` = 目录条目
|
||||
- `[[页面名]]` = 页面链接
|
||||
- `Tab` 缩进 = 层级嵌套
|
||||
|
||||
因此目录操作(新建/删除/重命名)的本质都是**读取 → 修改 → 更新 Sidebar 内容**。
|
||||
|
||||
### `callWikiAPI` 机制
|
||||
|
||||
由于 Wiki API 使用不同的网关域名,代码中实现了 `callWikiAPI` 辅助函数来临时切换 API 基地址:
|
||||
|
||||
```go
|
||||
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
|
||||
|
||||
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 }()
|
||||
// ... 执行 API 请求
|
||||
}
|
||||
```
|
||||
|
||||
### `fetchProjectID` 机制
|
||||
|
||||
Wiki API 需要 `projectId` 参数,而 CLI 输入只有 `owner/repo`。通过先调用标准 API 获取仓库信息,从中提取 `project_id`:
|
||||
|
||||
```
|
||||
1. GET /whale_hihihi/gitlink-cli.json → 获取 project_id: 1546648
|
||||
2. GET /wiki/open/wikiPages?projectId=1546648&... → 获取 Wiki 页面列表
|
||||
```
|
||||
|
||||
优先级:`project_id` > `repo_id` > `id`
|
||||
|
||||
> **注意:** `project_id` 和 `repo_id` 是不同的值。Wiki API 必须使用 `project_id`。
|
||||
|
||||
### 请求流程示例
|
||||
|
||||
**`wiki +list`:**
|
||||
```
|
||||
GET www.gitlink.org.cn/api/whale_hihihi/gitlink-cli.json → project_id
|
||||
GET gateway.gitlink.org.cn/api/wiki/open/wikiPages?projectId=... → 页面列表
|
||||
```
|
||||
|
||||
**`wiki +delete`(两步操作):**
|
||||
```
|
||||
GET www.gitlink.org.cn/api/.../gitlink-cli.json → project_id
|
||||
DELETE gateway.gitlink.org.cn/api/wiki/open/deleteWiki → 清空页面内容
|
||||
(sleep 2s)
|
||||
GET gateway.gitlink.org.cn/api/wiki/open/getWiki?pageName=_Sidebar → 读取 Sidebar
|
||||
PUT gateway.gitlink.org.cn/api/wiki/open/updateWiki → 更新 Sidebar(移除 [[pageName]])
|
||||
```
|
||||
|
||||
**`wiki +rename`(四步操作):**
|
||||
```
|
||||
GET .../getWiki?pageName=oldName → 获取旧页面内容
|
||||
POST .../createWiki → 创建新页面(复用旧内容)
|
||||
DELETE .../deleteWiki → 删除旧页面
|
||||
PUT .../updateWiki (_Sidebar) → [[oldName]] 替换为 [[newName]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、涉及文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `shortcuts/wiki/wiki.go` | 新增 | Wiki 9 个命令的实现 + Sidebar 操作辅助函数 |
|
||||
| `shortcuts/wiki/wiki_test.go` | 新增 | 单元测试 |
|
||||
| `shortcuts/register.go` | 修改 | 注册 wiki 模块 |
|
||||
| `skills/gitlink-wiki/SKILL.md` | 新增 | Skill 文档 |
|
||||
| `internal/client/client.go` | 已有 | `DoRaw` 方法(无需 `.json` 后缀的 API 调用) |
|
||||
|
||||
---
|
||||
|
||||
## 四、使用方法
|
||||
|
||||
### 列出 Wiki 页面
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +list --owner whale_hihihi --repo test
|
||||
```
|
||||
|
||||
返回示例:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"code": 200,
|
||||
"data": [
|
||||
{"sub_url": "_Sidebar", "title": "_Sidebar"},
|
||||
{"sub_url": "test", "title": "test"}
|
||||
],
|
||||
"msg": "操作成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `_Sidebar` 是 Wiki 系统自动生成的侧边栏导航页面,非手动创建。
|
||||
|
||||
### 查看 Wiki 页面
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +view --owner whale_hihihi --repo test --name test
|
||||
```
|
||||
|
||||
返回内容中 `content_base64` 字段为 base64 编码的页面内容(如 `MTIz` 解码后为 `123`)。
|
||||
|
||||
### 创建 Wiki 页面
|
||||
|
||||
```bash
|
||||
# 在根目录创建页面
|
||||
`./gitlink-cli.exe wiki +create --owner whale_hihihi --repo test \
|
||||
--name CLITest --content "Hello from CLI" --message "test create"
|
||||
|
||||
# 在指定目录下创建页面
|
||||
`./gitlink-cli.exe wiki +create --owner whale_hihihi --repo test \
|
||||
--name dirpage --content "page in dir" --dir mydir
|
||||
```
|
||||
|
||||
成功后返回 `code: 201` 及新页面的提交信息(sha、author、date)。
|
||||
|
||||
### 更新 Wiki 页面
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +update --owner whale_hihihi --repo test \
|
||||
--name test --content "Updated content" --message "test update"
|
||||
```
|
||||
|
||||
`--message` 可选,是 Git 提交信息。
|
||||
|
||||
### 删除 Wiki 页面
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +delete --owner whale_hihihi --repo test --name test
|
||||
```
|
||||
|
||||
内部执行两步操作:清空页面内容 → 清理 Sidebar 中的页面链接。
|
||||
|
||||
### 新建目录
|
||||
|
||||
```bash
|
||||
# 新建顶级目录
|
||||
`./gitlink-cli.exe wiki +mkdir --owner whale_hihihi --repo test --name mydir
|
||||
|
||||
# 在指定目录下新建子目录
|
||||
`./gitlink-cli.exe wiki +mkdir --owner whale_hihihi --repo test --name subdir --parent mydir
|
||||
```
|
||||
|
||||
本质是更新 `_Sidebar` 内容,添加 `- 目录名` 条目。
|
||||
|
||||
### 删除目录
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +rmdir --owner whale_hihihi --repo test --name mydir
|
||||
```
|
||||
|
||||
从 Sidebar 中移除 `- 目录名` 及其所有子项(子目录和页面链接)。**注意:不会删除目录下的实际 Wiki 页面,只移除 Sidebar 导航。**
|
||||
|
||||
### 重命名页面
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +rename --owner whale_hihihi --repo test \
|
||||
--name oldpage --new-name newpage
|
||||
```
|
||||
|
||||
内部执行四步操作:获取旧页面内容 → 创建新页面 → 删除旧页面 → 更新 Sidebar 链接。
|
||||
|
||||
### 重命名目录
|
||||
|
||||
```bash
|
||||
`./gitlink-cli.exe wiki +renamedir --owner whale_hihihi --repo test \
|
||||
--name olddir --new-name newdir
|
||||
```
|
||||
|
||||
在 Sidebar 中将 `- olddir` 替换为 `- newdir`。
|
||||
|
||||
---
|
||||
|
||||
## 五、测试方法
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
```bash
|
||||
go test -v ./shortcuts/wiki/
|
||||
```
|
||||
|
||||
测试用例:
|
||||
|
||||
| 测试 | 验证内容 |
|
||||
|------|----------|
|
||||
| `TestWikiList` | GET 请求发送到 `/wiki/open/wikiPages`,返回页面列表 |
|
||||
| `TestWikiView` | GET 请求包含 `pageName` query 参数 |
|
||||
| `TestWikiCreate` | POST 请求 body 包含 `pageName`、`content_base64`、`owner`、`repo` |
|
||||
| `TestWikiUpdate` | PUT 请求 body 包含 `pageName`、`message` 等字段 |
|
||||
| `TestWikiDelete` | DELETE + GET Sidebar + PUT 更新 Sidebar(验证三步操作) |
|
||||
|
||||
单元测试使用 `httptest` mock 服务器,不会请求真实 API。由于 `callWikiAPI` 检测到 `http://127.0.0.1` 前缀时跳过 BaseURL 切换,mock 测试可以正常运行。
|
||||
|
||||
### 5.2 真实 API 验证
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
go build -o gitlink-cli.exe .
|
||||
|
||||
# 需要 token 认证
|
||||
$env:GITLINK_TOKEN="<your_token>"
|
||||
|
||||
# 页面操作
|
||||
./gitlink-cli.exe wiki +list --owner whale_hihihi --repo test
|
||||
./gitlink-cli.exe wiki +view --owner whale_hihihi --repo test --name test
|
||||
./gitlink-cli.exe wiki +create --owner whale_hihihi --repo test --name CLITest --content "Hello"
|
||||
./gitlink-cli.exe wiki +update --owner whale_hihihi --repo test --name CLITest --content "Updated"
|
||||
./gitlink-cli.exe wiki +delete --owner whale_hihihi --repo test --name CLITest
|
||||
|
||||
# 目录操作
|
||||
./gitlink-cli.exe wiki +mkdir --owner whale_hihihi --repo test --name testdir
|
||||
./gitlink-cli.exe wiki +mkdir --owner whale_hihihi --repo test --name subdir --parent testdir
|
||||
./gitlink-cli.exe wiki +create --owner whale_hihihi --repo test --name dp1 --content "dir page" --dir testdir
|
||||
./gitlink-cli.exe wiki +renamedir --owner whale_hihihi --repo test --name testdir --new-name mydir
|
||||
./gitlink-cli.exe wiki +rename --owner whale_hihihi --repo test --name dp1 --new-name dp1_renamed
|
||||
./gitlink-cli.exe wiki +rmdir --owner whale_hihihi --repo test --name mydir
|
||||
```
|
||||
|
||||
使用 `--debug` 参数可以看到实际请求的 URL 和 body。
|
||||
|
||||
---
|
||||
|
||||
## 六、已知问题与设计说明
|
||||
|
||||
### 6.1 Wiki API 未记录在官方文档中
|
||||
|
||||
GitLink Swagger API 文档中没有 Wiki 相关接口。所有 Wiki API 端点均通过浏览器 F12 抓包发现。
|
||||
|
||||
### 6.2 `+delete` 的两步删除机制
|
||||
|
||||
GitLink 的 `deleteWiki` API **只清空页面内容,不删除页面条目**。页面条目保存在 `_Sidebar` 中。因此 `+delete` 命令采用两步操作:
|
||||
|
||||
1. 调用 `DELETE /wiki/open/deleteWiki`:清空页面内容
|
||||
2. 等待 2 秒后更新 `_Sidebar`:移除 `[[pageName]]` 链接
|
||||
|
||||
### 6.3 `_Sidebar` 系统页面
|
||||
|
||||
Wiki 页面列表中始终包含一个 `_Sidebar` 页面,这是 GitLink Wiki 系统自动生成的侧边栏配置页面。所有目录操作都通过修改此页面实现。
|
||||
|
||||
### 6.4 `+rmdir` 只移除导航,不删除页面
|
||||
|
||||
`+rmdir` 从 Sidebar 中移除目录及其子项的导航链接,但**不会删除目录下的实际 Wiki 页面文件**。这是 GitLink 的设计限制——目录只是 Sidebar 的组织结构,不是真正的文件系统目录。
|
||||
|
||||
---
|
||||
|
||||
## 七、API 端点参考
|
||||
|
||||
| 端点 | 方法 | 参数 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/wiki/open/wikiPages` | GET | query: `owner`, `repo`, `projectId` | 获取页面列表 |
|
||||
| `/wiki/open/getWiki` | GET | query: `owner`, `repo`, `projectId`, `pageName` | 获取页面内容 |
|
||||
| `/wiki/open/createWiki` | POST | body: `owner`, `repo`, `projectId`, `pageName`, `title`, `content_base64`, `message` | 创建页面 |
|
||||
| `/wiki/open/updateWiki` | PUT | body: `owner`, `repo`, `projectId`, `pageName`, `title`, `content_base64`, `message` | 更新页面(含 Sidebar) |
|
||||
| `/wiki/open/deleteWiki` | DELETE | body: `owner`, `repo`, `projectId`, `pageName` | 删除页面(仅清空内容) |
|
||||
|
||||
所有端点的 base URL:`https://gateway.gitlink.org.cn/api`
|
||||
|
||||
> **注:** 目录操作(mkdir/rmdir/renamedir)没有独立的 API 端点,均通过 `updateWiki` 修改 `_Sidebar` 页面内容实现。
|
||||
|
||||
---
|
||||
|
||||
## 八、Webhook 增强
|
||||
|
||||
### 8.1 概述
|
||||
|
||||
在 Webhook 领域原有 3 个命令(list、create、delete)的基础上,新增了 4 个命令,补全了 Webhook 管理的完整生命周期。
|
||||
|
||||
### 8.2 命令对比
|
||||
|
||||
| 原有命令 | 说明 | 新增命令 | 说明 |
|
||||
|----------|------|----------|------|
|
||||
| `webhook +list` | 列出 Webhook | **`webhook +view`** | 查看 Webhook 详情(URL、事件、密钥等) |
|
||||
| `webhook +create` | 创建 Webhook | **`webhook +update`** | 更新 Webhook 配置(URL、事件、密钥等) |
|
||||
| `webhook +delete` | 删除 Webhook | **`webhook +history`** | 查看 Webhook 推送历史(每次推送的状态和响应) |
|
||||
| | | **`webhook +test`** | 触发一次测试推送(验证 Webhook 是否正常工作) |
|
||||
|
||||
### 8.3 使用示例
|
||||
|
||||
```bash
|
||||
# 列出所有 Webhook
|
||||
`./gitlink-cli.exe webhook +list --owner whale_hihihi --repo test
|
||||
|
||||
# 创建 Webhook
|
||||
`./gitlink-cli.exe webhook +create --owner whale_hihihi --repo test \
|
||||
--url https://example.com/hook --events push --secret mysecret
|
||||
|
||||
# 查看 Webhook 详情
|
||||
`./gitlink-cli.exe webhook +view --owner whale_hihihi --repo test --id 51347
|
||||
|
||||
# 更新 Webhook 事件列表
|
||||
`./gitlink-cli.exe webhook +update --owner whale_hihihi --repo test \
|
||||
--id 51347 --events push,issues_only,pull_request_only
|
||||
|
||||
# 查看推送历史
|
||||
`./gitlink-cli.exe webhook +history --owner whale_hihihi --repo test --id 51347
|
||||
|
||||
# 测试推送
|
||||
`./gitlink-cli.exe webhook +test --owner whale_hihihi --repo test --id 51347
|
||||
|
||||
# 删除 Webhook
|
||||
`./gitlink-cli.exe webhook +delete --owner whale_hihihi --repo test --id 51347
|
||||
```
|
||||
|
||||
### 8.4 涉及文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `shortcuts/webhook/webhook.go` | 新增 view、update、tasks、test 4 个 Shortcut |
|
||||
| `shortcuts/webhook/webhook_test.go` | 新增 4 个单元测试 |
|
||||
|
||||
### 8.5 API 端点参考
|
||||
|
||||
| 端点 | 方法 | 说明 | 状态 |
|
||||
|------|------|------|------|
|
||||
| `/v1/{owner}/{repo}/webhooks` | GET | 列出 Webhook | 原有 |
|
||||
| `/v1/{owner}/{repo}/webhooks` | POST | 创建 Webhook | 原有 |
|
||||
| `/v1/{owner}/{repo}/webhooks/{id}` | DELETE | 删除 Webhook | 原有 |
|
||||
| `/v1/{owner}/{repo}/webhooks/{id}` | GET | 查看 Webhook 详情 | **新增** |
|
||||
| `/v1/{owner}/{repo}/webhooks/{id}` | PUT | 更新 Webhook | **新增** |
|
||||
| `/v1/{owner}/{repo}/webhooks/{id}/hooktasks` | GET | 推送历史 | **新增** |
|
||||
| `/v1/{owner}/{repo}/webhooks/{id}/tests` | POST | 测试推送 | **新增** |
|
||||
|
||||
### 8.6 真实 API 验证
|
||||
|
||||
全部 7 个命令在 `whale_hihihi/gitlink-cli` 项目上验证通过:
|
||||
|
||||
| 命令 | 验证结果 |
|
||||
|------|----------|
|
||||
| `+list` | ✅ 返回 2 个 webhook |
|
||||
| `+create` | ✅ 创建成功,返回 id |
|
||||
| `+view` | ✅ 返回完整 webhook 配置 |
|
||||
| `+update` | ✅ 事件列表更新成功 |
|
||||
| `+tasks` | ✅ 返回推送历史列表 |
|
||||
| `+test` | ✅ 测试推送成功 |
|
||||
| `+delete` | ✅ 删除成功 |
|
||||
Binary file not shown.
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# import-test-data.sh
|
||||
# Import 15 issues + 6 PRs from upstream (gitlink/gitlink-cli) into the
|
||||
# test repo (baoerjun/gitlink-cli) for repeatable testing.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/import-test-data.sh [ISSUE_COUNT] [PR_COUNT]
|
||||
#
|
||||
# ISSUE_COUNT Number of issues to import (default: 15)
|
||||
# PR_COUNT Number of PRs to import (default: 6)
|
||||
#
|
||||
# --yes Skip confirmation prompts
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DATA_DIR="$PROJECT_DIR/data"
|
||||
CLI="${PROJECT_DIR}/gitlink-cli"
|
||||
MANIFEST="$DATA_DIR/test-data-manifest.json"
|
||||
|
||||
UPSTREAM_OWNER="gitlink"
|
||||
TEST_OWNER="baoerjun"
|
||||
REPO="gitlink-cli"
|
||||
FORK_REMOTE="fork"
|
||||
|
||||
ISSUE_COUNT="${1:-15}"
|
||||
PR_COUNT="${2:-6}"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
log_step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check prerequisites
|
||||
# ---------------------------------------------------------------------------
|
||||
check_prereqs() {
|
||||
if [ ! -f "$CLI" ]; then
|
||||
log_error "gitlink-cli binary not found at $CLI"
|
||||
exit 1
|
||||
fi
|
||||
if ! git remote get-url "$FORK_REMOTE" &>/dev/null; then
|
||||
log_error "Git remote '$FORK_REMOTE' not found. Required for pushing PR branches."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$DATA_DIR"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotency guard: check for existing manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
check_existing_manifest() {
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
log_warn "Existing manifest found at $MANIFEST"
|
||||
log_warn "Test data may already exist in the test repo."
|
||||
echo ""
|
||||
read -r -p "Run restore first, then re-import? [y/N] " answer
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
log_info "Please run: scripts/restore-test-repo.sh first, then re-run this script."
|
||||
exit 0
|
||||
fi
|
||||
log_info "Continuing will overwrite the existing manifest..."
|
||||
fi
|
||||
|
||||
# Also warn if test repo already has issues beyond baseline
|
||||
local existing
|
||||
existing=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \
|
||||
issue +list --format json --limit 1 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'].get('total_count',0))" 2>/dev/null || echo "0")
|
||||
if [ "$existing" -gt 0 ] 2>/dev/null; then
|
||||
log_warn "Test repo already has $existing issue(s). The restore script will delete ALL of them."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git state save/restore
|
||||
# ---------------------------------------------------------------------------
|
||||
save_git_state() {
|
||||
ORIG_BRANCH="$(git branch --show-current)"
|
||||
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||
log_info "Stashing uncommitted changes before git operations..."
|
||||
git stash push -m "import-test-data: auto-stash $(date +%s)"
|
||||
STASHED=1
|
||||
else
|
||||
STASHED=0
|
||||
fi
|
||||
}
|
||||
|
||||
restore_git_state() {
|
||||
log_info "Restoring git state..."
|
||||
git checkout "$ORIG_BRANCH" 2>/dev/null || true
|
||||
if [ "${STASHED:-0}" -eq 1 ]; then
|
||||
git stash pop 2>/dev/null || log_warn "Could not pop stash"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Python helper: handles ALL CLI interactions involving JSON
|
||||
# ===========================================================================
|
||||
run_python_phase() {
|
||||
local phase="$1" # "issues" or "prs_fetch" or "pr_create"
|
||||
shift
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, sys, os, textwrap
|
||||
|
||||
CLI = os.environ['CLI_PATH']
|
||||
UPSTREAM_OWNER = os.environ['UPSTREAM_OWNER']
|
||||
TEST_OWNER = os.environ['TEST_OWNER']
|
||||
REPO = os.environ['REPO']
|
||||
ISSUE_COUNT = int(os.environ['ISSUE_COUNT'])
|
||||
PR_COUNT = int(os.environ['PR_COUNT'])
|
||||
DATA_DIR = os.environ['DATA_DIR']
|
||||
PHASE = os.environ['PY_PHASE']
|
||||
|
||||
def run_cli(*args, timeout=90):
|
||||
"""Run gitlink-cli, return parsed JSON. Exits on failure."""
|
||||
cmd = [CLI] + list(args)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
print(f"CLI error (exit={result.returncode}): {' '.join(cmd)}", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(f"stderr: {result.stderr.strip()[:500]}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(f"stdout: {result.stdout.strip()[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSON parse error: {e}", file=sys.stderr)
|
||||
print(f"Command: {' '.join(cmd)}", file=sys.stderr)
|
||||
print(f"stdout: {result.stdout[:800]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# =========================================================================
|
||||
# PHASE: issues
|
||||
# =========================================================================
|
||||
if PHASE == 'issues':
|
||||
print("[INFO] Fetching upstream issues...")
|
||||
result = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO,
|
||||
'issue', '+list', '--format', 'json', '--limit', str(ISSUE_COUNT))
|
||||
issues = result['data']['issues']
|
||||
print(f"[INFO] Got {len(issues)} issues from upstream")
|
||||
|
||||
# Save raw upstream data
|
||||
with open(os.path.join(DATA_DIR, 'upstream-issues.json'), 'w') as f:
|
||||
json.dump(issues, f, ensure_ascii=False, indent=2)
|
||||
|
||||
created = []
|
||||
for i, issue in enumerate(issues, 1):
|
||||
number = issue['number']
|
||||
subject = issue.get('subject', '(no title)')
|
||||
subject_short = subject[:80] + ('...' if len(subject) > 80 else '')
|
||||
print(f"[INFO] [{i}/{len(issues)}] Fetching issue #{number}: {subject_short}")
|
||||
|
||||
# Get full details (includes body/description)
|
||||
try:
|
||||
detail = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO,
|
||||
'issue', '+view', '--number', str(number), '--format', 'json')
|
||||
issue_data = detail.get('data', detail)
|
||||
body = (issue_data.get('description') or '').strip()
|
||||
priority_id = str(issue_data.get('priority_id') or '2')
|
||||
except Exception as e:
|
||||
print(f"[WARN] Could not fetch details for #{number}: {e}, using list data only")
|
||||
body = ''
|
||||
priority_id = '2'
|
||||
|
||||
# Create in test repo via python subprocess (safe body handling)
|
||||
print(f"[INFO] [{i}/{len(issues)}] Creating in test repo...")
|
||||
create_args = [
|
||||
'--owner', TEST_OWNER, '--repo', REPO,
|
||||
'issue', '+create', '--format', 'json',
|
||||
'--title', subject,
|
||||
'--priority-id', priority_id,
|
||||
]
|
||||
if body:
|
||||
create_args.extend(['--body', body])
|
||||
|
||||
try:
|
||||
create_result = run_cli(*create_args, timeout=30)
|
||||
new_data = create_result.get('data', create_result)
|
||||
new_id = new_data.get('id')
|
||||
new_number = new_data.get('project_issues_index')
|
||||
print(f"[OK] Created issue #{new_number} (db_id={new_id})")
|
||||
created.append({
|
||||
'upstream_number': number,
|
||||
'upstream_db_id': issue['database_id'],
|
||||
'test_db_id': new_id,
|
||||
'test_number': new_number,
|
||||
'subject': subject,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to create issue #{number}: {e}")
|
||||
created.append({
|
||||
'upstream_number': number,
|
||||
'upstream_db_id': issue['database_id'],
|
||||
'error': str(e),
|
||||
})
|
||||
|
||||
# Output marker + JSON for bash to capture
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps(created, ensure_ascii=False))
|
||||
|
||||
# =========================================================================
|
||||
# PHASE: prs_fetch — fetch upstream PR data
|
||||
# =========================================================================
|
||||
elif PHASE == 'prs_fetch':
|
||||
print("[INFO] Fetching upstream PRs...")
|
||||
result = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO,
|
||||
'pr', '+list', '--format', 'json', '--limit', str(PR_COUNT))
|
||||
prs = result['data']['pulls']
|
||||
print(f"[INFO] Got {len(prs)} PRs from upstream")
|
||||
|
||||
with open(os.path.join(DATA_DIR, 'upstream-prs.json'), 'w') as f:
|
||||
json.dump(prs, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Return structured data for bash
|
||||
pr_commands = []
|
||||
for i, pr in enumerate(prs, 1):
|
||||
title = pr.get('title', '(no title)')
|
||||
body = (pr.get('body') or '').strip()
|
||||
head_branch = f"test-import/pr-{i}"
|
||||
base_branch = pr.get('base', 'master')
|
||||
|
||||
title_short = title[:80] + ('...' if len(title) > 80 else '')
|
||||
print(f"[INFO] [{i}/{len(prs)}] {title_short}")
|
||||
|
||||
pr_commands.append({
|
||||
'index': i,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'head': head_branch,
|
||||
'base': base_branch,
|
||||
'upstream_id': pr['id'],
|
||||
'upstream_index': pr['index'],
|
||||
})
|
||||
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps(pr_commands, ensure_ascii=False))
|
||||
|
||||
# =========================================================================
|
||||
# PHASE: pr_create_one — create a single PR via CLI (called after git ops)
|
||||
# =========================================================================
|
||||
elif PHASE == 'pr_create_one':
|
||||
# Read PR data from temp file (avoids shell quoting issues with body text)
|
||||
pr_data_file = os.environ['PR_DATA_FILE']
|
||||
with open(pr_data_file, 'r') as f:
|
||||
pr = json.load(f)
|
||||
|
||||
title = pr['title']
|
||||
body = pr.get('body', '')
|
||||
head = pr['head']
|
||||
base = pr['base']
|
||||
|
||||
create_args = [
|
||||
'--owner', TEST_OWNER, '--repo', REPO,
|
||||
'pr', '+create', '--format', 'json',
|
||||
'--title', title,
|
||||
'--head', head,
|
||||
'--base', base,
|
||||
]
|
||||
if body:
|
||||
create_args.extend(['--body', body])
|
||||
|
||||
try:
|
||||
result = run_cli(*create_args, timeout=30)
|
||||
pr_data = result.get('data', result)
|
||||
created_number = pr_data.get('index') or pr_data.get('number') or pr_data.get('id')
|
||||
print(f"[OK] Created PR #{created_number}")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'ok': True, 'number': str(created_number), 'title': title}))
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to create PR: {e}")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'ok': False, 'error': str(e)}))
|
||||
|
||||
# =========================================================================
|
||||
# PHASE: build_manifest — final manifest construction
|
||||
# =========================================================================
|
||||
elif PHASE == 'build_manifest':
|
||||
issues_json = os.environ['ISSUES_JSON']
|
||||
prs_json = os.environ['PRS_JSON']
|
||||
from datetime import datetime
|
||||
|
||||
issues = json.loads(issues_json)
|
||||
prs = json.loads(prs_json)
|
||||
|
||||
manifest = {
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'upstream': 'gitlink/gitlink-cli',
|
||||
'test_repo': 'baoerjun/gitlink-cli',
|
||||
'issues': issues,
|
||||
'prs': prs,
|
||||
'summary': {
|
||||
'issues_imported': len([i for i in issues if 'test_db_id' in i]),
|
||||
'issues_failed': len([i for i in issues if 'error' in i]),
|
||||
'prs_created': len([p for p in prs if p.get('ok')]),
|
||||
'prs_failed': len([p for p in prs if not p.get('ok')]),
|
||||
}
|
||||
}
|
||||
|
||||
manifest_path = os.environ['MANIFEST_PATH']
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[OK] Manifest saved to {manifest_path}")
|
||||
s = manifest['summary']
|
||||
print(f"[INFO] Issues: {s['issues_imported']} imported, {s['issues_failed']} failed")
|
||||
print(f"[INFO] PRs: {s['prs_created']} created, {s['prs_failed']} failed")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create a PR branch, push it, then call Python to create the PR via API
|
||||
# ---------------------------------------------------------------------------
|
||||
create_single_pr() {
|
||||
local index="$1"
|
||||
local branch="$2"
|
||||
local title="$3"
|
||||
local body="$4"
|
||||
local base="$5"
|
||||
|
||||
# All diagnostic output goes to stderr so stdout contains only JSON
|
||||
{
|
||||
log_info " [$index/$PR_COUNT] Creating branch '$branch' from '$base'..."
|
||||
|
||||
# Clean up any previous attempt
|
||||
git branch -D "$branch" 2>/dev/null || true
|
||||
git push "$FORK_REMOTE" --delete "$branch" 2>/dev/null || true
|
||||
|
||||
git checkout master
|
||||
git checkout -b "$branch"
|
||||
|
||||
# Create a dummy file as PR content
|
||||
local marker_file="test-pr-import-${index}.md"
|
||||
printf '%s\n' "# Test PR #${index}" "" "Imported from upstream gitlink/gitlink-cli." \
|
||||
"Upstream PR title: ${title}" > "$marker_file"
|
||||
git add "$marker_file"
|
||||
git commit -m "test: import PR #${index} from upstream" \
|
||||
-m "Source: gitlink/gitlink-cli" \
|
||||
-m "This is test data for gitlink-cli testing." --allow-empty
|
||||
|
||||
log_info " [$index/$PR_COUNT] Pushing '$branch' to $FORK_REMOTE..."
|
||||
git push "$FORK_REMOTE" "$branch"
|
||||
} >&2
|
||||
|
||||
# Write PR data to temp file to avoid any shell quoting issues
|
||||
local tmp_pr_data
|
||||
tmp_pr_data=$(mktemp)
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
json.dump({
|
||||
'title': data['title'],
|
||||
'body': data['body'],
|
||||
'head': data['head'],
|
||||
'base': data['base'],
|
||||
}, sys.stdout, ensure_ascii=False)
|
||||
" <<< "$pr_data" > "$tmp_pr_data"
|
||||
|
||||
# Now create the PR via Python (reads body from temp file, no shell interpolation)
|
||||
log_info " [$index/$PR_COUNT] Creating PR via API..." >&2
|
||||
export PR_DATA_FILE="$tmp_pr_data"
|
||||
export PY_PHASE="pr_create_one"
|
||||
local api_result
|
||||
api_result="$(run_python_phase "pr_create")"
|
||||
rm -f "$tmp_pr_data"
|
||||
|
||||
# Show progress from the Python output
|
||||
echo "$api_result" | grep -E '^\[(OK|ERROR)\]' >&2 || true
|
||||
|
||||
# Extract the result JSON (this goes to stdout for capture by caller)
|
||||
local pr_json
|
||||
pr_json=$(echo "$api_result" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)
|
||||
echo "$pr_json"
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
main() {
|
||||
echo ""
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} GitLink Test Data Import Script ${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo ""
|
||||
log_info "Upstream : $UPSTREAM_OWNER/$REPO"
|
||||
log_info "Test repo: $TEST_OWNER/$REPO"
|
||||
log_info "Issues to import : $ISSUE_COUNT"
|
||||
log_info "PRs to import : $PR_COUNT"
|
||||
echo ""
|
||||
|
||||
check_prereqs
|
||||
check_existing_manifest
|
||||
|
||||
# Export env vars for Python subprocess
|
||||
export CLI_PATH="$CLI"
|
||||
export UPSTREAM_OWNER TEST_OWNER REPO ISSUE_COUNT PR_COUNT DATA_DIR MANIFEST_PATH="$MANIFEST"
|
||||
|
||||
# ===== Phase 1: Import Issues =====
|
||||
echo ""
|
||||
log_step "===== Phase 1/4: Import Issues ====="
|
||||
export PY_PHASE="issues"
|
||||
local issues_output
|
||||
issues_output="$(run_python_phase "issues")"
|
||||
|
||||
# Show progress lines
|
||||
echo "$issues_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true
|
||||
|
||||
# Extract result JSON
|
||||
local issues_json
|
||||
issues_json="$(echo "$issues_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)"
|
||||
|
||||
if [ -z "$issues_json" ]; then
|
||||
log_error "Issue import produced no output. Check CLI connectivity."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local ok_count
|
||||
ok_count=$(echo "$issues_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len([i for i in d if 'test_db_id' in i]))")
|
||||
log_info "Issues created: $ok_count / $ISSUE_COUNT"
|
||||
|
||||
# ===== Phase 2: Fetch PR data from upstream =====
|
||||
echo ""
|
||||
log_step "===== Phase 2/4: Fetch PR data from upstream ====="
|
||||
export PY_PHASE="prs_fetch"
|
||||
local prs_output
|
||||
prs_output="$(run_python_phase "prs_fetch")"
|
||||
echo "$prs_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true
|
||||
|
||||
local prs_json
|
||||
prs_json="$(echo "$prs_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)"
|
||||
|
||||
if [ -z "$prs_json" ]; then
|
||||
log_error "PR fetch produced no output."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pr_count
|
||||
pr_count=$(echo "$prs_json" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
log_info "PRs to create: $pr_count"
|
||||
|
||||
# ===== Phase 3: Create PR branches, push, and create PRs =====
|
||||
echo ""
|
||||
log_step "===== Phase 3/4: Create PRs (branches + API) ====="
|
||||
|
||||
save_git_state
|
||||
# Ensure we start from a clean master
|
||||
git checkout master 2>/dev/null || true
|
||||
|
||||
local pr_results_json="["
|
||||
local first=true
|
||||
for idx in $(seq 0 $((pr_count - 1))); do
|
||||
local pr_data index branch title body base
|
||||
pr_data=$(echo "$prs_json" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)[$idx]))")
|
||||
index=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['index'])")
|
||||
branch=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['head'])")
|
||||
title=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])")
|
||||
body=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['body'])")
|
||||
base=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['base'])")
|
||||
|
||||
local pr_result
|
||||
pr_result=$(create_single_pr "$index" "$branch" "$title" "$body" "$base")
|
||||
|
||||
if [ "$first" = true ]; then
|
||||
first=false
|
||||
else
|
||||
pr_results_json+=","
|
||||
fi
|
||||
|
||||
# Parse the result and add to our collection
|
||||
local pr_entry
|
||||
pr_entry=$(echo "$pr_result" | python3 -c "
|
||||
import sys, json
|
||||
r = json.load(sys.stdin)
|
||||
entry = {
|
||||
'index': $index,
|
||||
'branch': '${branch}',
|
||||
'title': r.get('title', ''),
|
||||
'ok': r.get('ok', False),
|
||||
'number': r.get('number', ''),
|
||||
'error': r.get('error', ''),
|
||||
}
|
||||
print(json.dumps(entry))
|
||||
" 2>/dev/null || echo "{\"index\": $index, \"branch\": \"$branch\", \"ok\": false, \"error\": \"parse failed\"}")
|
||||
pr_results_json+="$pr_entry"
|
||||
done
|
||||
pr_results_json+="]"
|
||||
|
||||
restore_git_state
|
||||
|
||||
# ===== Phase 4: Build Manifest =====
|
||||
echo ""
|
||||
log_step "===== Phase 4/4: Save Manifest ====="
|
||||
|
||||
# Normalize PR results JSON
|
||||
pr_results_json=$(echo "$pr_results_json" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)))" 2>/dev/null || echo "$pr_results_json")
|
||||
|
||||
export ISSUES_JSON="$issues_json"
|
||||
export PRS_JSON="$pr_results_json"
|
||||
export PY_PHASE="build_manifest"
|
||||
run_python_phase "build_manifest"
|
||||
|
||||
# ===== Summary =====
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Import Complete! ${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
log_info "Manifest: $MANIFEST"
|
||||
log_info "Verify with:"
|
||||
echo ""
|
||||
echo " ${CLI} --owner ${TEST_OWNER} --repo ${REPO} issue +list --limit 5"
|
||||
echo " ${CLI} --owner ${TEST_OWNER} --repo ${REPO} pr +list --limit 5"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -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/<skill>/examples/<scenario>-workflow.sh`,串联 CLI → Python → 报告产物,
|
||||
即赛题要求的「可复现执行脚本」。
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"""collect.py — 子赛题四共享数据采集器。
|
||||
|
||||
每个采集器是对 `gitlink-cli <domain> +<verb>` 或 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 <index>`。
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
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 <login> --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 ""
|
||||
|
|
@ -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 <domain> +<verb>` 列表命令做多页累积。
|
||||
|
||||
依赖命令支持 --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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
"""hotspot.py — 科研热点追踪(全栈重构版)。
|
||||
|
||||
输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库,对每个仓库:
|
||||
- 拉取 repo_info(stars / forks / 更新时间 / 贡献者)
|
||||
- 拉取 issue + pr 列表(活跃讨论按评论数排序)
|
||||
- 从 description + readme 抽取主题标签
|
||||
- 聚合学者/团队贡献网络
|
||||
|
||||
输出:hotspot.json(结构化数据)+ report.md(中文简报)。
|
||||
|
||||
取数与算法分离:collect() 在线取数,compute_*() 纯函数离线可测。
|
||||
|
||||
用法:
|
||||
python hotspot.py --keywords "deep learning,机器学习" --repos-limit 12 --out ./out
|
||||
python hotspot.py --keywords "computer vision" # 仅打印 JSON
|
||||
"""
|
||||
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) -> int:
|
||||
"""仓库热度综合评分。
|
||||
|
||||
公式:stars + forks × 2 + 近期更新加成(30 天内加成最高)。
|
||||
满分无上限,用于仓库间横向排序。
|
||||
"""
|
||||
base = stars + forks * 2
|
||||
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], repos_limit: int = 12) -> dict:
|
||||
"""在线取数:多关键词搜索 → 去重 → 仓库详情 → issue/PR → 贡献者 → README。
|
||||
|
||||
返回原始数据字典,供 compute() 消费。
|
||||
"""
|
||||
# Step 1: 多关键词搜索
|
||||
seen: dict[str, dict] = {}
|
||||
for kw in keywords:
|
||||
results = c.search_repos(kw, limit=repos_limit)
|
||||
for proj in results:
|
||||
full = c.repo_fullname(proj)
|
||||
if not full:
|
||||
continue
|
||||
if full not in seen:
|
||||
seen[full] = {
|
||||
"fullname": full,
|
||||
"matched_keywords": [],
|
||||
"project": proj,
|
||||
"info": None,
|
||||
"issues": [],
|
||||
"prs": [],
|
||||
"contributors": [],
|
||||
"readme": "",
|
||||
"languages": {},
|
||||
}
|
||||
seen[full]["matched_keywords"].append(kw)
|
||||
|
||||
repos = list(seen.values())
|
||||
|
||||
# 先按 stars 粗排,截断到 repos_limit 再深度取数(节省 API 调用)
|
||||
def _star_sort_key(r: dict) -> int:
|
||||
p = r["project"]
|
||||
return -(c.as_int(p.get("praises_count") or p.get("stars_count") or 0))
|
||||
|
||||
repos.sort(key=_star_sort_key)
|
||||
repos = repos[:repos_limit]
|
||||
|
||||
# Step 2: 逐仓库取详情
|
||||
for r in repos:
|
||||
full = r["fullname"]
|
||||
parts = full.split("/", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
owner, repo = parts[0], parts[1]
|
||||
|
||||
# repo_info
|
||||
info = c.repo_info(owner, repo)
|
||||
r["info"] = info if isinstance(info, dict) else {}
|
||||
|
||||
# issues(取 open 状态,限制 5 页以控制耗时)
|
||||
r["issues"] = c.issues(owner, repo, state="open", max_pages=3, page_size=30)
|
||||
|
||||
# PRs(取 open 状态)
|
||||
r["prs"] = c.prs(owner, repo, state="open", max_pages=3, page_size=30)
|
||||
|
||||
# contributors
|
||||
r["contributors"] = c.contributors(owner, repo)
|
||||
|
||||
# readme(前 4000 字符,仅用于主题抽取)
|
||||
r["readme"] = (c.readme(owner, repo) or "")[:4000]
|
||||
|
||||
# languages
|
||||
r["languages"] = c.languages(owner, repo)
|
||||
|
||||
return {"keywords": keywords, "repos": repos, "repos_limit": repos_limit}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 离线计算(纯函数,不联网)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
score = compute_trending_score(stars, forks, days)
|
||||
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,
|
||||
"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 [],
|
||||
"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 [])
|
||||
meta = output.get("meta") or {}
|
||||
lines = [
|
||||
f"# 🔬 科研热点追踪报告",
|
||||
f"",
|
||||
f"> 关键词:{kw}",
|
||||
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['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 科研仓库"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--keywords", "-k",
|
||||
default="deep learning,机器学习",
|
||||
help="搜索关键词,逗号分隔(默认 deep learning,机器学习)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--repos-limit", type=int, default=12,
|
||||
help="最大分析仓库数(默认 12)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--out", "-o",
|
||||
default="",
|
||||
help="输出目录(不传则仅打印 JSON 到 stdout)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
kw_list = [k.strip() for k in args.keywords.split(",") if k.strip()]
|
||||
if not kw_list:
|
||||
print(json.dumps({"ok": False, "error": "keywords required"}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
# 取数
|
||||
sys.stderr.write(f"[hotspot] 关键词: {kw_list} 上限: {args.repos_limit}\n")
|
||||
sys.stderr.flush()
|
||||
raw = collect(kw_list, args.repos_limit)
|
||||
|
||||
# 计算
|
||||
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()
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
"""lineage.py — S1 仓库级科研项目洞悉。
|
||||
|
||||
对一个 GitLink 科研仓库做「项目谱系(lineage)」分析:从默认分支提交时间线、
|
||||
合并 PR 的演进模式、文档演进、实验/评测文件组织、关键创新点五个角度,回答
|
||||
「这个科研项目是怎么一步步长成现在这样的、值得引用/复现到哪一步」。
|
||||
|
||||
技术栈 = Go 出数据(经 gitlink-cli + collect.py 取数)+ Python 做算法(本文件)。
|
||||
所有 GitLink 操作经 gitlink-cli,禁止用 gh/glab(见 gitlink-shared)。
|
||||
|
||||
用法:
|
||||
python lineage.py --owner mindspore-Ecosystem --repo mindspore --out ./out
|
||||
python lineage.py --owner O --repo R # 仅打印 JSON
|
||||
"""
|
||||
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 = info.get("default_branch") or "master"
|
||||
|
||||
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:以默认分支为唯一分支,按合并时间标里程碑 PR。"""
|
||||
lines = ["```mermaid", "gitGraph"]
|
||||
lines.append(f" commit id: \"{result['default_branch']} 起点\"")
|
||||
prs = result["pr_merge_patterns"]
|
||||
inno = result["innovation_points"]
|
||||
inno_titles = {it["description"] for it in inno}
|
||||
# 在时间线上穿插 commit / merge 标记(每 ~3 个 PR 一个 merge,里程碑标 hotfix)
|
||||
step = 0
|
||||
for p in prs:
|
||||
lines.append(" commit")
|
||||
step += 1
|
||||
if step % 3 == 0:
|
||||
tag = " 创新点" if any(p["title"].strip() == t for t in inno_titles) else ""
|
||||
lines.append(f" commit id: \"#{p['number']}{tag}\" tag: \"{p['merged_time'] or 'PR'}\"")
|
||||
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()
|
||||
|
|
@ -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"]}<br/>(目标仓库)"]']
|
||||
for i, m in enumerate(result["candidates"][:8], 1):
|
||||
nid = f"C{i}"
|
||||
lines.append(f' {nid}["{m["login"]}<br/>{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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"(?<!\d)1[3-9]\d{9}(?!\d)"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 算法:纯函数(单测对象,不联网)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def identify_license(text: str) -> 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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,636 @@
|
|||
"""visual.py — S6 科研成果可视化沉淀。
|
||||
|
||||
把一个科研仓库的「成果」沉淀成一张可交互的可视化报告:开发时间线(commit/issue/pr 周粒度
|
||||
趋势)、贡献者×周热力图、语言占比饼图、里程碑甘特。同时从 README/提交里抽取论文引用
|
||||
(arXiv / DOI)并按目录对仓库产物做分类,便于科研工作者一眼看清「成果产出节奏 + 引用源头」。
|
||||
|
||||
数据全部经 gitlink-cli 获取(commit via Raw API、issue/pr/milestone/repo +list、contributors、
|
||||
languages、readme、tree)。算法纯函数化(按周分桶 / 热力矩阵 / 论文链接抽取 / 产物分类),
|
||||
便于离线单测。
|
||||
|
||||
用法:
|
||||
python visual.py --owner mindspore-Ecosystem --repo mindspore --weeks 26 --out ./out
|
||||
python visual.py --owner O --repo R # 仅打印 JSON
|
||||
"""
|
||||
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="master", 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 {}
|
||||
|
||||
# 行布局:timeline(跨整行) | heatmap | pie | gantt
|
||||
fig = make_subplots(
|
||||
rows=4, cols=2,
|
||||
specs=[
|
||||
[{"colspan": 2}, None],
|
||||
[{"colspan": 2}, None],
|
||||
[{"type": "domain"}, {"type": "scatter"}],
|
||||
[{"colspan": 2}, None],
|
||||
],
|
||||
row_heights=[0.25, 0.30, 0.25, 0.20],
|
||||
vertical_spacing=0.08,
|
||||
subplot_titles=("开发时间线(周粒度)", "贡献者 × 周提交热力图",
|
||||
"语言占比", "里程碑甘特(due_date)"),
|
||||
)
|
||||
|
||||
# 1) 时间线折线
|
||||
fig.add_trace(go.Scatter(x=tl["labels"], y=tl["commits"], name="commits",
|
||||
mode="lines+markers", line=dict(color="#636efa")), row=1, col=1)
|
||||
fig.add_trace(go.Scatter(x=tl["labels"], y=tl["issues"], name="issues",
|
||||
mode="lines+markers", line=dict(color="#ef553b")), row=1, col=1)
|
||||
fig.add_trace(go.Scatter(x=tl["labels"], y=tl["prs"], name="prs",
|
||||
mode="lines+markers", line=dict(color="#00cc96")), row=1, col=1)
|
||||
|
||||
# 2) 贡献热力图
|
||||
if hm["users"]:
|
||||
fig.add_trace(go.Heatmap(
|
||||
z=hm["matrix"], x=hm["weeks"], y=hm["users"],
|
||||
colorscale="Blues", name="contributions",
|
||||
colorbar=dict(title="提交数", len=0.25, y=0.78),
|
||||
), row=2, col=1)
|
||||
|
||||
# 3a) 语言饼图
|
||||
if langs:
|
||||
labels = list(langs.keys())
|
||||
values = []
|
||||
for v in langs.values():
|
||||
# GitLink languages 形如 {"Python":"99.7%"};剥离 %
|
||||
s = str(v).strip().rstrip("%")
|
||||
try:
|
||||
values.append(float(s))
|
||||
except ValueError:
|
||||
values.append(0.0)
|
||||
fig.add_trace(go.Pie(labels=labels, values=values, name="languages",
|
||||
textinfo="label+percent"), row=3, col=1)
|
||||
|
||||
# 3b) 里程碑甘特(用散点的水平线段近似)
|
||||
gantt = result["milestones"]
|
||||
for g in gantt[:20]:
|
||||
title = g["title"] or "(milestone)"
|
||||
start = g["start"]
|
||||
due = g["due"]
|
||||
fig.add_trace(go.Scatter(
|
||||
x=[start, due], y=[title, title],
|
||||
mode="lines+markers",
|
||||
line=dict(color="#ffa15a", width=6),
|
||||
marker=dict(size=8),
|
||||
showlegend=False, hovertemplate=f"{title}<br>%{{x}}",
|
||||
), row=3, col=2)
|
||||
|
||||
fig.update_layout(
|
||||
title=f"科研成果可视化 — {result['repo']}(最近 {result['weeks']} 周)",
|
||||
height=1100, width=1100,
|
||||
legend=dict(orientation="h", y=1.02),
|
||||
margin=dict(l=40, r=40, t=80, b=40),
|
||||
)
|
||||
fig.update_xaxes(row=1, col=1, tickangle=-45)
|
||||
|
||||
return fig.to_html(full_html=True, include_plotlyjs="cdn",
|
||||
default_width="100%", default_height="1100px")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# restore-test-repo.sh
|
||||
# Delete all issues and PRs from the test repo (baoerjun/gitlink-cli),
|
||||
# then re-import clean test data from upstream.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/restore-test-repo.sh [--yes] [--keep-branches]
|
||||
#
|
||||
# --yes Skip confirmation prompts
|
||||
# --keep-branches Don't delete test-import/* branches (keep local + remote)
|
||||
# --no-reimport Skip re-import after cleanup (just delete, don't repopulate)
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DATA_DIR="$PROJECT_DIR/data"
|
||||
CLI="${PROJECT_DIR}/gitlink-cli"
|
||||
IMPORT_SCRIPT="$SCRIPT_DIR/import-test-data.sh"
|
||||
MANIFEST="$DATA_DIR/test-data-manifest.json"
|
||||
|
||||
TEST_OWNER="baoerjun"
|
||||
REPO="gitlink-cli"
|
||||
FORK_REMOTE="fork"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
log_step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
|
||||
YES_MODE=0
|
||||
KEEP_BRANCHES=0
|
||||
NO_REIMPORT=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes) YES_MODE=1 ;;
|
||||
--keep-branches) KEEP_BRANCHES=1 ;;
|
||||
--no-reimport) NO_REIMPORT=1 ;;
|
||||
*) log_error "Unknown option: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
confirm() {
|
||||
if [ "$YES_MODE" -eq 1 ]; then
|
||||
return 0
|
||||
fi
|
||||
local prompt="$1"
|
||||
read -r -p "$prompt [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prerequisite checks
|
||||
# ---------------------------------------------------------------------------
|
||||
check_prereqs() {
|
||||
if [ ! -f "$CLI" ]; then
|
||||
log_error "gitlink-cli binary not found at $CLI"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
log_warn "No manifest found at $MANIFEST"
|
||||
log_warn "Will proceed with full cleanup (delete ALL issues/PRs in test repo)."
|
||||
if ! confirm "Continue without manifest?"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Show what will be deleted
|
||||
# ---------------------------------------------------------------------------
|
||||
show_plan() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo -e "${YELLOW} RESTORE PLAN ${NC}"
|
||||
echo -e "${YELLOW}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Count issues
|
||||
local issue_count
|
||||
issue_count=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \
|
||||
issue +list --format json --limit 1 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('total_count',0))" 2>/dev/null || echo "?")
|
||||
echo -e " Issues to delete: ${RED}${issue_count}${NC}"
|
||||
|
||||
# Count open PRs
|
||||
local pr_count
|
||||
pr_count=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \
|
||||
pr +list --format json --limit 1 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('total_count',0))" 2>/dev/null || echo "?")
|
||||
echo -e " PRs to close: ${RED}${pr_count}${NC}"
|
||||
|
||||
# Branches
|
||||
local branch_count
|
||||
branch_count=$(git branch -a 2>/dev/null | grep -c 'test-import/pr-' || echo "0")
|
||||
echo -e " Local branches: ${RED}${branch_count}${NC} (test-import/pr-*)"
|
||||
|
||||
if [ "$NO_REIMPORT" -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e " After cleanup: re-import ${GREEN}15 issues + 6 PRs${NC} from upstream"
|
||||
else
|
||||
echo ""
|
||||
echo -e " ${YELLOW}--no-reimport: will NOT repopulate after cleanup${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: Delete all issues
|
||||
# ---------------------------------------------------------------------------
|
||||
delete_all_issues() {
|
||||
log_step "Phase 1/4: Deleting all issues from $TEST_OWNER/$REPO ..."
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, sys, os
|
||||
|
||||
CLI = os.environ['CLI_PATH']
|
||||
TEST_OWNER = os.environ['TEST_OWNER']
|
||||
REPO = os.environ['REPO']
|
||||
YES_MODE = os.environ.get('YES_MODE', '0')
|
||||
|
||||
def run_cli(*args, timeout=60):
|
||||
cmd = [CLI] + list(args)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
print(f"CLI error: {' '.join(cmd)}", file=sys.stderr)
|
||||
print(f"stderr: {result.stderr.strip()[:500]}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
# First get the total count
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'issue', '+list', '--format', 'json', '--limit', '1')
|
||||
if not result:
|
||||
print("[INFO] Could not query issues. Repo may already be empty.")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'deleted': 0}))
|
||||
sys.exit(0)
|
||||
|
||||
total = result.get('data', {}).get('total_count', 0)
|
||||
print(f"[INFO] Total issues in test repo: {total}")
|
||||
|
||||
if total == 0:
|
||||
print("[INFO] No issues to delete.")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'deleted': 0}))
|
||||
sys.exit(0)
|
||||
|
||||
# Fetch ALL issues page by page (API supports --page, not offset)
|
||||
PAGE_SIZE = 50
|
||||
all_ids = []
|
||||
page_num = 1
|
||||
|
||||
while len(all_ids) < total:
|
||||
print(f"[INFO] Fetching page {page_num} (limit={PAGE_SIZE})...")
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'issue', '+list', '--format', 'json',
|
||||
'--limit', str(PAGE_SIZE),
|
||||
'--page', str(page_num))
|
||||
|
||||
if not result:
|
||||
print(f"[WARN] Failed to fetch page {page_num}")
|
||||
break
|
||||
|
||||
issues = result.get('data', {}).get('issues', [])
|
||||
if not issues:
|
||||
break # No more issues
|
||||
|
||||
page_ids = [str(issue['database_id']) for issue in issues]
|
||||
all_ids.extend(page_ids)
|
||||
print(f"[INFO] Page {page_num}: fetched {len(page_ids)} issue IDs")
|
||||
page_num += 1
|
||||
|
||||
print(f"[INFO] Total issue IDs collected: {len(all_ids)}")
|
||||
|
||||
if not all_ids:
|
||||
print("[INFO] No issues to delete.")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'deleted': 0}))
|
||||
sys.exit(0)
|
||||
|
||||
# Batch delete (API may have limits on batch size, do in chunks)
|
||||
BATCH_DELETE_SIZE = 50
|
||||
deleted_count = 0
|
||||
failed_ids = []
|
||||
|
||||
for i in range(0, len(all_ids), BATCH_DELETE_SIZE):
|
||||
chunk = all_ids[i:i + BATCH_DELETE_SIZE]
|
||||
ids_str = ','.join(chunk)
|
||||
print(f"[INFO] Deleting batch {i // BATCH_DELETE_SIZE + 1} ({len(chunk)} issues)...")
|
||||
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'issue', '+batch-delete',
|
||||
'--ids', ids_str,
|
||||
'--confirm',
|
||||
timeout=120)
|
||||
|
||||
if result and result.get('ok'):
|
||||
deleted_count += len(chunk)
|
||||
print(f"[OK] Deleted {len(chunk)} issues")
|
||||
else:
|
||||
print(f"[WARN] Batch delete may have partially failed for {len(chunk)} issues")
|
||||
failed_ids.extend(chunk)
|
||||
|
||||
# If batch delete failed, try individual deletes (fallback)
|
||||
if failed_ids:
|
||||
print(f"[WARN] {len(failed_ids)} issues failed batch delete, trying individual...")
|
||||
# There's no individual delete; just report the failure
|
||||
print(f"[WARN] Could not delete {len(failed_ids)} issues. Manual cleanup may be needed.")
|
||||
|
||||
print(f"[INFO] Successfully deleted: {deleted_count} issues")
|
||||
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'deleted': deleted_count, 'failed': len(failed_ids)}))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Close all PRs
|
||||
# ---------------------------------------------------------------------------
|
||||
close_all_prs() {
|
||||
log_step "Phase 2/4: Closing all PRs in $TEST_OWNER/$REPO ..."
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, sys, os
|
||||
|
||||
CLI = os.environ['CLI_PATH']
|
||||
TEST_OWNER = os.environ['TEST_OWNER']
|
||||
REPO = os.environ['REPO']
|
||||
|
||||
def run_cli(*args, timeout=60):
|
||||
cmd = [CLI] + list(args)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
print(f"CLI error (non-fatal): {' '.join(cmd)}", file=sys.stderr)
|
||||
print(f"stderr: {result.stderr.strip()[:300]}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
# Get total count
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'pr', '+list', '--format', 'json', '--limit', '1')
|
||||
if not result:
|
||||
print("[INFO] Could not query PRs. Repo may have none.")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'closed': 0}))
|
||||
sys.exit(0)
|
||||
|
||||
total = result.get('data', {}).get('total_count', 0)
|
||||
print(f"[INFO] Total PRs in test repo: {total}")
|
||||
|
||||
if total == 0:
|
||||
print("[INFO] No PRs to close.")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'closed': 0}))
|
||||
sys.exit(0)
|
||||
|
||||
# Fetch all open PRs
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'pr', '+list', '--format', 'json', '--limit', str(max(total, 200)))
|
||||
|
||||
if not result:
|
||||
print("[ERROR] Failed to fetch PR list")
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'closed': 0, 'error': 'fetch failed'}))
|
||||
sys.exit(1)
|
||||
|
||||
prs = result.get('data', {}).get('pulls', [])
|
||||
|
||||
# Only close open PRs
|
||||
open_prs = [p for p in prs if p.get('status') == 'open']
|
||||
print(f"[INFO] Open PRs to close: {len(open_prs)}")
|
||||
|
||||
closed = 0
|
||||
failed = 0
|
||||
|
||||
for pr in open_prs:
|
||||
pr_index = pr.get('index') or pr.get('number')
|
||||
title_short = (pr.get('title', '')[:60]) + ('...' if len(pr.get('title', '')) > 60 else '')
|
||||
print(f"[INFO] Closing PR #{pr_index}: {title_short}")
|
||||
|
||||
result = run_cli('--owner', TEST_OWNER, '--repo', REPO,
|
||||
'pr', '+refuse', '--id', str(pr_index), timeout=60)
|
||||
if result and result.get('ok'):
|
||||
closed += 1
|
||||
print(f"[OK] Closed PR #{pr_index}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f"[WARN] Failed to close PR #{pr_index}")
|
||||
|
||||
print(f"[INFO] Closed: {closed}, Failed: {failed}")
|
||||
|
||||
print("__PYTHON_RESULT__")
|
||||
print(json.dumps({'closed': closed, 'failed': failed}))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: Delete test branches
|
||||
# ---------------------------------------------------------------------------
|
||||
delete_test_branches() {
|
||||
log_step "Phase 3/4: Cleaning up test branches ..."
|
||||
|
||||
# Find local test-import branches
|
||||
local local_branches
|
||||
local_branches=$(git branch 2>/dev/null | grep 'test-import/pr-' | sed 's/^[* ]*//' || true)
|
||||
|
||||
if [ -n "$local_branches" ]; then
|
||||
echo "$local_branches" | while read -r branch; do
|
||||
[ -z "$branch" ] && continue
|
||||
log_info " Deleting local branch: $branch"
|
||||
git branch -D "$branch" 2>/dev/null || log_warn " Could not delete local branch $branch"
|
||||
done
|
||||
else
|
||||
log_info " No local test-import/pr-* branches found."
|
||||
fi
|
||||
|
||||
# Find and delete remote branches
|
||||
local remote_branches
|
||||
remote_branches=$(git ls-remote --heads "$FORK_REMOTE" 2>/dev/null \
|
||||
| grep 'refs/heads/test-import/pr-' \
|
||||
| sed 's/.*refs\/heads\///' || true)
|
||||
|
||||
if [ -n "$remote_branches" ]; then
|
||||
echo "$remote_branches" | while read -r branch; do
|
||||
[ -z "$branch" ] && continue
|
||||
log_info " Deleting remote branch: $FORK_REMOTE/$branch"
|
||||
git push "$FORK_REMOTE" --delete "$branch" 2>/dev/null \
|
||||
|| log_warn " Could not delete remote branch $branch"
|
||||
done
|
||||
else
|
||||
log_info " No remote test-import/pr-* branches on $FORK_REMOTE."
|
||||
fi
|
||||
|
||||
# Clean up test PR marker files
|
||||
log_info " Cleaning up test PR marker files..."
|
||||
git checkout master 2>/dev/null || true
|
||||
if ls test-pr-import-*.md &>/dev/null 2>&1; then
|
||||
rm -f test-pr-import-*.md
|
||||
log_info " Removed test-pr-import-*.md files"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: Re-import
|
||||
# ---------------------------------------------------------------------------
|
||||
reimport_test_data() {
|
||||
log_step "Phase 4/4: Re-importing clean test data ..."
|
||||
|
||||
# Remove old manifest so import starts fresh
|
||||
rm -f "$MANIFEST"
|
||||
|
||||
if [ ! -f "$IMPORT_SCRIPT" ]; then
|
||||
log_error "Import script not found at $IMPORT_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Running: $IMPORT_SCRIPT"
|
||||
echo ""
|
||||
|
||||
# Pass through --yes if we're in yes mode
|
||||
if [ "$YES_MODE" -eq 1 ]; then
|
||||
bash "$IMPORT_SCRIPT"
|
||||
else
|
||||
bash "$IMPORT_SCRIPT"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
main() {
|
||||
echo ""
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED} GitLink Test Repo RESTORE Script ${NC}"
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo ""
|
||||
log_warn "This will DELETE ALL issues and close ALL PRs in:"
|
||||
log_warn " $TEST_OWNER/$REPO"
|
||||
echo ""
|
||||
|
||||
check_prereqs
|
||||
show_plan
|
||||
|
||||
if ! confirm "Proceed with restore?"; then
|
||||
log_info "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${RED}>>> Starting restore in 2 seconds...${NC}"
|
||||
sleep 2
|
||||
|
||||
# Export env vars for Python subprocess
|
||||
export CLI_PATH="$CLI"
|
||||
export TEST_OWNER REPO YES_MODE="$YES_MODE"
|
||||
|
||||
# ---- Phase 1: Delete Issues ----
|
||||
echo ""
|
||||
local issues_output
|
||||
issues_output=$(delete_all_issues)
|
||||
echo "$issues_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true
|
||||
local issues_result
|
||||
issues_result=$(echo "$issues_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)
|
||||
local deleted_issues
|
||||
deleted_issues=$(echo "$issues_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('deleted',0))" 2>/dev/null || echo "0")
|
||||
log_info "Issues deleted: $deleted_issues"
|
||||
|
||||
# ---- Phase 2: Close PRs ----
|
||||
echo ""
|
||||
local prs_output
|
||||
prs_output=$(close_all_prs)
|
||||
echo "$prs_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true
|
||||
local prs_result
|
||||
prs_result=$(echo "$prs_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)
|
||||
log_info "PRs closed: $(echo "$prs_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('closed',0))" 2>/dev/null || echo "0")"
|
||||
|
||||
# ---- Phase 3: Clean up branches ----
|
||||
echo ""
|
||||
if [ "$KEEP_BRANCHES" -eq 0 ]; then
|
||||
delete_test_branches
|
||||
else
|
||||
log_info "Phase 3/4: Skipping branch cleanup (--keep-branches)"
|
||||
fi
|
||||
|
||||
# ---- Phase 4: Re-import ----
|
||||
echo ""
|
||||
if [ "$NO_REIMPORT" -eq 0 ]; then
|
||||
reimport_test_data
|
||||
else
|
||||
log_info "Phase 4/4: Skipping re-import (--no-reimport)"
|
||||
fi
|
||||
|
||||
# ---- Summary ----
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Restore Complete! ${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
log_info "Issues deleted: $deleted_issues"
|
||||
if [ "$NO_REIMPORT" -eq 0 ]; then
|
||||
log_info "Test data re-imported: 15 issues + 6 PRs"
|
||||
else
|
||||
log_warn "Re-import skipped. Test repo is now empty."
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
main
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 未找到,请确认仓库是否存在")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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/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 +19,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 +41,56 @@ 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(),
|
||||
"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",
|
||||
"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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue