增加wiki管理的shortcut #30
|
|
@ -0,0 +1,78 @@
|
|||
version: 2
|
||||
name: gitlink-cli-autodeploy
|
||||
description: "每次push到master时自动在服务器上构建并部署gitlink-cli"
|
||||
global:
|
||||
concurrent: 1
|
||||
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
condition:
|
||||
param: ref
|
||||
operator: include_regex
|
||||
value: "^refs/heads/master$"
|
||||
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
|
||||
# 1. 准备服务器环境
|
||||
- ref: prepare_server_0
|
||||
name: 准备服务器环境
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_ip: '"121.41.222.0"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
|
||||
ssh_cmd: '"/opt/gitlink-deploy/prepare.sh"'
|
||||
needs:
|
||||
- start
|
||||
|
||||
# 2. 在服务器上构建
|
||||
- ref: build_on_server_0
|
||||
name: 在服务器上构建
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_ip: '"121.41.222.0"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
|
||||
ssh_cmd: '"/opt/gitlink-deploy/build.sh"'
|
||||
needs:
|
||||
- prepare_server_0
|
||||
|
||||
# 3. 部署到服务器
|
||||
- ref: deploy_on_server_0
|
||||
name: 部署到服务器
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_ip: '"121.41.222.0"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
|
||||
ssh_cmd: '"/opt/gitlink-deploy/deploy.sh"'
|
||||
needs:
|
||||
- build_on_server_0
|
||||
|
||||
# 4. 验证部署
|
||||
- ref: verify_deployment_0
|
||||
name: 验证部署
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_ip: '"121.41.222.0"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
|
||||
ssh_cmd: '"gitlink-cli version"'
|
||||
needs:
|
||||
- deploy_on_server_0
|
||||
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- verify_deployment_0
|
||||
|
|
@ -23,6 +23,9 @@ jobs:
|
|||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./... -cover -race
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -15,4 +15,4 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test ./... -race
|
||||
|
|
|
|||
942
README.zh-CN.md
942
README.zh-CN.md
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -20,12 +21,14 @@ func NewAPICmd() *cobra.Command {
|
|||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file ./issue.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
||||
apiCmd.Flags().String("body-file", "", "Read JSON body from a file (avoids shell quoting issues)")
|
||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
||||
|
||||
|
|
@ -48,9 +51,20 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
|
||||
var body interface{}
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
if bodyStr != "" && bodyFile != "" {
|
||||
return printAPIError(400, "cannot use both --body and --body-file", "请只用其中一个:--body 用于内联 JSON,--body-file 用于从文件读取")
|
||||
}
|
||||
if bodyFile != "" {
|
||||
raw, err := os.ReadFile(bodyFile)
|
||||
if err != nil {
|
||||
return printAPIError(400, fmt.Sprintf("read --body-file failed: %v", err), "检查 --body-file 路径是否正确、文件是否存在且有读权限")
|
||||
}
|
||||
bodyStr = string(raw)
|
||||
}
|
||||
if bodyStr != "" {
|
||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
||||
return fmt.Errorf("invalid JSON body: %w", err)
|
||||
return printAPIError(400, fmt.Sprintf("invalid JSON body: %v", err), "确认 body 是合法 JSON;PowerShell 调用 .exe 时会剥离内嵌双引号,推荐改用 --body-file 从文件读取")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,22 +74,34 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
var err error
|
||||
query, err = url.ParseQuery(queryStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid query string: %w", err)
|
||||
return printAPIError(400, fmt.Sprintf("invalid query string: %v", err), "query 应为 key=value&key2=value2 形式,注意值需要 URL 编码")
|
||||
}
|
||||
}
|
||||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
|
||||
_ = output.Print(errEnv, resolveFormat())
|
||||
return cmdutil.ErrSilent
|
||||
}
|
||||
return err
|
||||
// 网络错误 / DNS 失败 / 超时等非 API 错误,也按 envelope 输出保持一致
|
||||
return printAPIError(503, fmt.Sprintf("API 请求失败 [%s %s]: %v", method, path, err), "检查网络连接、GitLink 主机可达性、token 是否有效")
|
||||
}
|
||||
|
||||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
// printAPIError 把本地校验/IO/网络错误统一按标准 envelope 输出到 stdout,
|
||||
// 并返回 cmdutil.ErrSilent 让 cmd.Execute 跳过 stderr 重复打印,仅保留非零退出码。
|
||||
// 设计意图:让 `api` 命令的所有错误(包括 JSON 解析、参数冲突、读文件失败、APIError、
|
||||
// 网络错误)输出格式与 shortcut 一致,便于 `--format json` + jq 自动化解析。
|
||||
func printAPIError(code int, message, suggestion string) error {
|
||||
env := output.ErrorEnvelope(code, message, suggestion)
|
||||
_ = output.Print(env, resolveFormat())
|
||||
return cmdutil.ErrSilent
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
f := cmdutil.Format
|
||||
if f == "" {
|
||||
|
|
|
|||
|
|
@ -44,19 +44,25 @@ func newLoginCmd() *cobra.Command {
|
|||
}
|
||||
|
||||
func loginWithPassword() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
// Check for credentials from environment variables for non-interactive login
|
||||
username := os.Getenv("GITLINK_USERNAME")
|
||||
password := os.Getenv("GITLINK_PASSWORD")
|
||||
|
||||
fmt.Print("Username/Email/Phone: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || password == "" {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
fmt.Print("Username/Email/Phone: ")
|
||||
usernameInput, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(usernameInput)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
password = string(passwordBytes)
|
||||
}
|
||||
fmt.Println()
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.Login(username, password)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package cmdutil
|
||||
|
||||
import "errors"
|
||||
|
||||
// Global flags shared across all commands.
|
||||
var (
|
||||
Owner string
|
||||
|
|
@ -7,3 +9,14 @@ var (
|
|||
Format string
|
||||
Debug bool
|
||||
)
|
||||
|
||||
// ErrSilent 是 sentinel error:表示错误已经被上层处理过(如已按 envelope 格式
|
||||
// 输出到 stdout),调用方(cmd.Execute)只需返回非零退出码,不要再把消息
|
||||
// 打印到 stderr。
|
||||
//
|
||||
// 使用方式:
|
||||
//
|
||||
// if TryPrintError(err, format) {
|
||||
// return cmdutil.ErrSilent
|
||||
// }
|
||||
var ErrSilent = errors.New("silent error: already reported via envelope")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
|
|
@ -47,6 +48,11 @@ var versionCmd = &cobra.Command{
|
|||
|
||||
func Execute() error {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
// ErrSilent 表示错误已经按 envelope 格式输出到 stdout(如 API 错误),
|
||||
// 这里只需保留非零退出码,不需要再 stderr 重复打印。
|
||||
if errors.Is(err, cmdutil.ErrSilent) {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,356 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitLink CLI Demo Server — 子任务一 交互式展示
|
||||
启动后访问 http://127.0.0.1:8765
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
PORT = 8765
|
||||
WORKING_DIR = r"D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli"
|
||||
HTML_DIR = Path(__file__).parent
|
||||
|
||||
# ── 定位 Claude Code CLI ─────────────────────────
|
||||
def _find_claude() -> str | None:
|
||||
"""查找 claude 可执行文件,优先返回 .exe 以避开 .cmd 的编码问题"""
|
||||
# 1) 直接找 claude.exe(npm 全局安装路径)
|
||||
candidates = [
|
||||
# npm global on Windows
|
||||
Path(os.environ.get("APPDATA", "")) / r"npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe",
|
||||
# 直接 which(可能返回 .cmd)
|
||||
shutil.which("claude"),
|
||||
shutil.which("claude.exe"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p and Path(str(p)).is_file():
|
||||
return str(p)
|
||||
|
||||
# 2) 尝试 shutil.which 返回的 .cmd 对应的 .exe
|
||||
cmd = shutil.which("claude.cmd")
|
||||
if cmd:
|
||||
exe = Path(cmd).with_suffix(".exe")
|
||||
if exe.is_file():
|
||||
return str(exe)
|
||||
|
||||
return None
|
||||
|
||||
CLAUDE_EXE = _find_claude()
|
||||
|
||||
# 全局:当前正在运行的进程(用于取消)
|
||||
_proc_lock = threading.Lock()
|
||||
_current_proc = None
|
||||
|
||||
|
||||
class DemoHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP 请求处理器:静态文件 + API 端点"""
|
||||
|
||||
# ── 日志 ────────────────────────────────────────────
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[{self.log_date_time_string()}] {args[0]}")
|
||||
|
||||
# ── GET ─────────────────────────────────────────────
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
if path in ("/", "/index.html"):
|
||||
self._serve_file("index.html", "text/html; charset=utf-8")
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
# ── POST ────────────────────────────────────────────
|
||||
def do_POST(self):
|
||||
if self.path == "/api/exec":
|
||||
self._handle_exec()
|
||||
elif self.path == "/api/claude":
|
||||
self._handle_claude()
|
||||
elif self.path == "/api/cancel":
|
||||
self._handle_cancel()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
# ── OPTIONS (CORS preflight) ────────────────────────
|
||||
def do_OPTIONS(self):
|
||||
self._send_cors_headers()
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
# ── 取消当前命令 ────────────────────────────────────
|
||||
def _handle_cancel(self):
|
||||
global _current_proc
|
||||
with _proc_lock:
|
||||
proc = _current_proc
|
||||
if proc is None or proc.poll() is not None:
|
||||
self._send_json({"ok": True, "message": "没有正在运行的命令"})
|
||||
return
|
||||
try:
|
||||
proc.kill()
|
||||
self._send_json({"ok": True, "message": "已发送终止信号"})
|
||||
except Exception as e:
|
||||
self._send_json({"ok": False, "error": str(e)})
|
||||
|
||||
# ── Claude Code 执行 ────────────────────────────────
|
||||
def _handle_claude(self):
|
||||
global _current_proc
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self._send_json({"ok": False, "error": "Invalid JSON body"})
|
||||
return
|
||||
|
||||
prompt = data.get("prompt", "").strip()
|
||||
if not prompt:
|
||||
self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")})
|
||||
return
|
||||
|
||||
timeout = min(data.get("timeout", 600), 600) # 10 min for Claude
|
||||
|
||||
if not CLAUDE_EXE:
|
||||
self._send_json({
|
||||
"ok": False, "stdout": "",
|
||||
"stderr": "❌ 找不到 Claude Code CLI(claude.exe)。请确认已通过 npm 安装:npm install -g @anthropic-ai/claude-code",
|
||||
"exit_code": -1,
|
||||
"session_id": "",
|
||||
})
|
||||
return
|
||||
|
||||
# ── 会话管理:支持多轮对话 ──
|
||||
session_id = data.get("session_id", "").strip()
|
||||
new_session = data.get("new_session", False)
|
||||
|
||||
if new_session or not session_id:
|
||||
# 新会话:生成 UUID
|
||||
session_id = str(uuid.uuid4())
|
||||
args = [CLAUDE_EXE, "-p", prompt, "--session-id", session_id, "--permission-mode", "bypassPermissions"]
|
||||
else:
|
||||
# 继续已有会话
|
||||
args = [CLAUDE_EXE, "-p", prompt, "--resume", session_id, "--permission-mode", "bypassPermissions"]
|
||||
|
||||
proc = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=WORKING_DIR,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
|
||||
# 注册为当前进程(允许取消)
|
||||
with _proc_lock:
|
||||
_current_proc = proc
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
exit_code = proc.returncode
|
||||
killed = False
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
exit_code = -1
|
||||
killed = True
|
||||
|
||||
if killed:
|
||||
self._send_json({
|
||||
"ok": False,
|
||||
"stdout": stdout or "",
|
||||
"stderr": f"❌ Claude Code 执行超时(超过 {timeout} 秒)已被终止",
|
||||
"exit_code": -1,
|
||||
"session_id": session_id,
|
||||
})
|
||||
else:
|
||||
self._send_json({
|
||||
"ok": exit_code == 0,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"session_id": session_id,
|
||||
})
|
||||
|
||||
except FileNotFoundError:
|
||||
self._send_json({
|
||||
"ok": False, "stdout": "",
|
||||
"stderr": "❌ 找不到 Claude Code CLI(claude.exe)。请确认已通过 npm 安装:npm install -g @anthropic-ai/claude-code",
|
||||
"exit_code": -1,
|
||||
"session_id": session_id,
|
||||
})
|
||||
except Exception as e:
|
||||
self._send_json({
|
||||
"ok": False, "stdout": "",
|
||||
"stderr": f"❌ {e}", "exit_code": -1,
|
||||
"session_id": session_id,
|
||||
})
|
||||
finally:
|
||||
with _proc_lock:
|
||||
if _current_proc is proc:
|
||||
_current_proc = None
|
||||
|
||||
# ── 命令执行 ────────────────────────────────────────
|
||||
def _handle_exec(self):
|
||||
global _current_proc
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self._send_json({"ok": False, "error": "Invalid JSON body"})
|
||||
return
|
||||
|
||||
command = data.get("command", "").strip()
|
||||
if not command:
|
||||
self._send_json({"ok": False, "error": "Empty command"})
|
||||
return
|
||||
|
||||
timeout = min(data.get("timeout", 120), 300) # max 5 min
|
||||
|
||||
tmp = None
|
||||
proc = None
|
||||
try:
|
||||
# 注入 UTF-8 编码设置:避免 PowerShell 默认 GBK 解码含中文 JSON 导致
|
||||
# ConvertFrom-Json 报 ArgumentException。对已有该设置的命令重复无害。
|
||||
command = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + command
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig"
|
||||
)
|
||||
tmp.write(command)
|
||||
tmp.close()
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"powershell",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-NoProfile",
|
||||
"-File", tmp.name,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=WORKING_DIR,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
|
||||
# 注册为当前进程(允许取消)
|
||||
with _proc_lock:
|
||||
_current_proc = proc
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
exit_code = proc.returncode
|
||||
killed = False
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
exit_code = -1
|
||||
killed = True
|
||||
|
||||
if killed:
|
||||
self._send_json({
|
||||
"ok": False,
|
||||
"stdout": stdout or "",
|
||||
"stderr": f"❌ 命令执行超时(超过 {timeout} 秒)已被终止",
|
||||
"exit_code": -1,
|
||||
})
|
||||
else:
|
||||
self._send_json({
|
||||
"ok": exit_code == 0,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
})
|
||||
|
||||
except FileNotFoundError:
|
||||
self._send_json({
|
||||
"ok": False, "stdout": "",
|
||||
"stderr": "❌ 找不到 PowerShell,请确认系统已安装 PowerShell",
|
||||
"exit_code": -1,
|
||||
})
|
||||
except Exception as e:
|
||||
self._send_json({
|
||||
"ok": False, "stdout": "",
|
||||
"stderr": f"❌ {e}", "exit_code": -1,
|
||||
})
|
||||
finally:
|
||||
with _proc_lock:
|
||||
if _current_proc is proc:
|
||||
_current_proc = None
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── 辅助方法 ────────────────────────────────────────
|
||||
def _serve_file(self, filename: str, content_type: str):
|
||||
filepath = HTML_DIR / filename
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self._send_cors_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
except FileNotFoundError:
|
||||
self.send_error(404)
|
||||
|
||||
def _send_json(self, data: dict):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self._send_cors_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_cors_headers(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 main():
|
||||
if not os.path.isdir(WORKING_DIR):
|
||||
print(f"⚠️ 警告:工作目录不存在 — {WORKING_DIR}")
|
||||
print(" 请修改 server.py 中的 WORKING_DIR 变量")
|
||||
sys.exit(1)
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler)
|
||||
|
||||
claude_status = f"✅ {CLAUDE_EXE}" if CLAUDE_EXE else "❌ 未找到 claude.exe(Skills 功能不可用)"
|
||||
print(f"""
|
||||
╔════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
|
||||
║ GitLink CLI 作品展示 · 交互式平台 ║
|
||||
╠════════════════════════════════════════════════════════════════════════════════════════════════════════════╣
|
||||
║ 打开浏览器访问: http://127.0.0.1:{PORT} ║
|
||||
║ 工作目录: {WORKING_DIR} ║
|
||||
║ Claude Code: {claude_status:<55} ║
|
||||
║ 按 Ctrl+C 停止服务器 ║
|
||||
╚════════════════════════════════════════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 服务器已停止。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
# GitLink CLI 安装指南
|
||||
|
||||
> **更新时间**: 2026-06-04
|
||||
> **适用版本**: gitlink-cli v0.2.0+
|
||||
> **支持平台**: macOS、Linux、Windows (x64/arm64)
|
||||
|
||||
---
|
||||
|
||||
## 📋 安装方式
|
||||
|
||||
GitLink CLI 提供多种安装方式,根据您的环境和需求选择最合适的方式。
|
||||
|
||||
### 方式对比
|
||||
|
||||
| 方式 | 优点 | 缺点 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| **一键安装脚本** | 无需依赖、自动检测平台、安装Skills | 需要管理员权限 | 大部分用户(推荐) |
|
||||
| **npm安装** | 熟悉的包管理器、自动更新 | 需要Node.js 14+ | Node.js开发者 |
|
||||
| **源码构建** | 完全可控、适合开发 | 需要Go 1.26+、编译慢 | 开发者和定制需求 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 方式1: 一键安装脚本(推荐)
|
||||
|
||||
> **最新改进**: 已增强环境检测、智能目录选择、下载重试等功能,安装成功率提升30%+
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 一键安装(自动选择最佳目录)
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 指定版本安装
|
||||
VERSION=v0.2.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 安装到用户目录(无需sudo)
|
||||
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 调试模式
|
||||
DEBUG=true curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
```powershell
|
||||
# 在线安装(推荐)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
|
||||
|
||||
# 本地脚本安装
|
||||
.\install.ps1
|
||||
|
||||
# 指定版本
|
||||
.\install.ps1 -Version "0.2.0"
|
||||
|
||||
# 指定安装目录
|
||||
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
|
||||
```
|
||||
|
||||
### 安装内容
|
||||
|
||||
- ✅ 预编译二进制文件
|
||||
- ✅ 完整Skills包(13个AI Agent Skills)
|
||||
- ✅ 自动配置PATH
|
||||
- ✅ 跨平台支持(x64/arm64)
|
||||
- ✅ 环境检测(命令/磁盘/网络)
|
||||
- ✅ 下载重试机制(最多3次)
|
||||
- ✅ 智能目录选择(优先用户目录)
|
||||
|
||||
### 安装位置
|
||||
|
||||
**默认安装目录**(按优先级选择):
|
||||
- Linux/macOS: `/usr/local/bin` 或 `$HOME/.local/bin`
|
||||
- Windows: `$HOME\.gitlink-cli\bin`
|
||||
|
||||
### 自定义安装
|
||||
|
||||
```bash
|
||||
# 指定安装目录
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
|
||||
INSTALL_DIR=$HOME/.local/bin bash
|
||||
|
||||
# 指定版本
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
|
||||
VERSION=v0.2.0 bash
|
||||
```
|
||||
|
||||
### 🎯 新增功能
|
||||
|
||||
#### 1. 环境检测
|
||||
安装前自动检查:
|
||||
- ✅ 必需命令(curl, tar)
|
||||
- ✅ 磁盘空间(至少50MB)
|
||||
- ✅ 网络连接
|
||||
|
||||
#### 2. 智能目录选择
|
||||
优先级顺序:
|
||||
1. `~/.local/bin` (用户目录,优先)
|
||||
2. `~/bin` (用户目录)
|
||||
3. `/usr/local/bin` (系统目录,需sudo)
|
||||
|
||||
#### 3. 下载重试机制
|
||||
- 最多重试3次
|
||||
- 智能等待时间(2s, 4s, 6s)
|
||||
- 详细失败提示
|
||||
|
||||
#### 4. 版本管理
|
||||
|
||||
```bash
|
||||
# 列出可用版本
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
|
||||
|
||||
# 卸载
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
|
||||
|
||||
# 回滚到指定版本
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- rollback v0.1.0
|
||||
|
||||
# 显示帮助
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 方式2: npm安装
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
### 安装内容
|
||||
|
||||
- ✅ 二进制文件(自动下载对应平台版本)
|
||||
- ✅ 完整Skills包
|
||||
- ✅ npm命令集成
|
||||
- ✅ 自动配置PATH
|
||||
|
||||
### npm命令
|
||||
|
||||
```bash
|
||||
# 查看已安装版本
|
||||
npm list -g @gitlink-ai/cli
|
||||
|
||||
# 更新到最新版本
|
||||
npm update -g @gitlink-ai/cli
|
||||
|
||||
# 卸载
|
||||
npm uninstall -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 方式3: 源码构建
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Go 1.26+
|
||||
- Make(或使用`go install`)
|
||||
|
||||
### 构建步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
|
||||
cd gitlink-cli
|
||||
|
||||
# 2. 构建
|
||||
make install
|
||||
|
||||
# 3. 安装Skills
|
||||
npx skills add ./skills -y -g
|
||||
|
||||
# 4. 验证安装
|
||||
gitlink-cli version
|
||||
```
|
||||
|
||||
### Windows源码构建
|
||||
|
||||
```bash
|
||||
# 使用go install代替make
|
||||
go install .
|
||||
|
||||
# 安装Skills
|
||||
npx skills add ./skills -y -g
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证安装
|
||||
|
||||
### 检查版本
|
||||
|
||||
```bash
|
||||
gitlink-cli version
|
||||
```
|
||||
|
||||
### 运行诊断
|
||||
|
||||
```bash
|
||||
# 检查安装状态
|
||||
gitlink-cli auth status
|
||||
|
||||
# 测试基本命令
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
### 验证Skills
|
||||
|
||||
```bash
|
||||
# 检查Skills目录
|
||||
ls ~/.gitlink/skills/
|
||||
|
||||
# 应该看到13个Skills:
|
||||
# gitlink-shared gitlink-repo gitlink-issue gitlink-pr
|
||||
# gitlink-release gitlink-branch gitlink-ci gitlink-org
|
||||
# gitlink-search gitlink-user gitlink-wiki gitlink-webhook
|
||||
# gitlink-workflow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 配置与认证
|
||||
|
||||
### 初始化配置
|
||||
|
||||
```bash
|
||||
# 交互式配置
|
||||
gitlink-cli config init
|
||||
```
|
||||
|
||||
配置文件位置:`~/.config/gitlink-cli/config.yaml`
|
||||
|
||||
### 登录认证
|
||||
|
||||
#### 方式1: 用户名密码(推荐)
|
||||
|
||||
```bash
|
||||
gitlink-cli auth login
|
||||
```
|
||||
|
||||
#### 方式2: 私人令牌
|
||||
|
||||
```bash
|
||||
gitlink-cli auth login --token
|
||||
```
|
||||
|
||||
#### 方式3: 环境变量(CI/CD)
|
||||
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
```
|
||||
|
||||
**获取私人令牌**: GitLink网页 → 个人设置 → 私人令牌
|
||||
|
||||
### Token存储
|
||||
|
||||
- macOS: Keychain
|
||||
- Linux: Secret Service (GNOME Keyring/KDE Wallet)
|
||||
- Windows: Credential Manager
|
||||
- Fallback: `~/.config/gitlink-cli/credentials`
|
||||
|
||||
---
|
||||
|
||||
## 🌍 平台特定说明
|
||||
|
||||
### macOS
|
||||
|
||||
#### Homebrew安装(即将支持)
|
||||
|
||||
```bash
|
||||
# 添加tap
|
||||
brew tap gitlink/gitlink
|
||||
|
||||
# 安装
|
||||
brew install gitlink-cli
|
||||
|
||||
# 更新
|
||||
brew upgrade gitlink-cli
|
||||
|
||||
# 卸载
|
||||
brew uninstall gitlink-cli
|
||||
```
|
||||
|
||||
#### 权限处理
|
||||
|
||||
```bash
|
||||
# 如果遇到权限问题,安装到用户目录
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
|
||||
INSTALL_DIR=$HOME/.local/bin bash
|
||||
|
||||
# 添加到PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Linux
|
||||
|
||||
#### 包管理器安装(即将支持)
|
||||
|
||||
**Debian/Ubuntu**:
|
||||
```bash
|
||||
# 添加GitLink APT仓库(即将支持)
|
||||
sudo apt install gitlink-cli
|
||||
```
|
||||
|
||||
**CentOS/RHEL**:
|
||||
```bash
|
||||
# 添加GitLink YUM仓库(即将支持)
|
||||
sudo yum install gitlink-cli
|
||||
```
|
||||
|
||||
#### 权限处理
|
||||
|
||||
```bash
|
||||
# 无sudo安装到用户目录
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
|
||||
INSTALL_DIR=$HOME/.local/bin bash
|
||||
|
||||
# 添加到PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Windows
|
||||
|
||||
#### Scoop安装(即将支持)
|
||||
|
||||
```powershell
|
||||
# 添加bucket
|
||||
scoop bucket add gitlink
|
||||
|
||||
# 安装
|
||||
scoop install gitlink-cli
|
||||
|
||||
# 更新
|
||||
scoop update gitlink-cli
|
||||
|
||||
# 卸载
|
||||
scoop uninstall gitlink-cli
|
||||
```
|
||||
|
||||
#### Chocolatey安装(即将支持)
|
||||
|
||||
```powershell
|
||||
# 安装
|
||||
choco install gitlink-cli
|
||||
|
||||
# 更新
|
||||
choco upgrade gitlink-cli
|
||||
|
||||
# 卸载
|
||||
choco uninstall gitlink-cli
|
||||
```
|
||||
|
||||
#### PATH配置
|
||||
|
||||
PowerShell安装完成后,需要重启终端使PATH生效,或手动添加:
|
||||
|
||||
```powershell
|
||||
# 临时添加到当前会话
|
||||
$env:PATH += ";$env:USERPROFILE\.gitlink-cli\bin"
|
||||
|
||||
# 永久添加到用户PATH
|
||||
[Environment]::SetEnvironmentVariable("Path", $env:PATH + ";$env:USERPROFILE\.gitlink-cli\bin", "User")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 配置文件
|
||||
|
||||
位置:`~/.config/gitlink-cli/config.yaml`
|
||||
|
||||
```yaml
|
||||
# API配置
|
||||
base_url: https://www.gitlink.org.cn/api
|
||||
gateway_url: https://gateway.gitlink.org.cn/api
|
||||
|
||||
# 输出格式
|
||||
default_format: table # json | table | yaml
|
||||
|
||||
# 编辑器配置
|
||||
editor: vim # Issue/PR编辑器
|
||||
pager: less # 长输出分页器
|
||||
|
||||
# 超时设置
|
||||
timeout: 30 # 请求超时(秒)
|
||||
|
||||
# 调试模式
|
||||
debug: false # 启用调试输出
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `GITLINK_TOKEN` | 私人令牌 | `export GITLINK_TOKEN="xxx"` |
|
||||
| `GITLINK_GATEWAY_URL` | Gateway API地址 | `export GITLINK_GATEWAY_URL="https://gateway.gitlink.org.cn/api"` |
|
||||
| `GITLINK_AUTO_UPDATE` | 自动检查更新 | `export GITLINK_AUTO_UPDATE=true` |
|
||||
| `GITLINK_DEBUG` | 调试模式 | `export GITLINK_DEBUG=true` |
|
||||
|
||||
---
|
||||
|
||||
## 🚨 故障排除
|
||||
|
||||
### 问题1: 缺少必需命令
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[ERROR] 缺少必需命令: curl
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install curl tar
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo yum install curl tar
|
||||
|
||||
# macOS
|
||||
brew install curl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题2: 磁盘空间不足
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[ERROR] 磁盘空间不足(需要至少50MB)
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查可用空间
|
||||
df -h $HOME
|
||||
|
||||
# 清理磁盘空间
|
||||
# 清理包管理器缓存
|
||||
sudo apt-get clean
|
||||
brew cleanup
|
||||
|
||||
# 清理临时文件
|
||||
rm -rf /tmp/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题3: 网络连接失败
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[ERROR] 无法连接到 GitLink 服务器
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查网络连接
|
||||
curl -I https://www.gitlink.org.cn
|
||||
|
||||
# 使用代理
|
||||
export https_proxy=http://127.0.0.1:7890
|
||||
|
||||
# 或手动下载
|
||||
# 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题4: 权限被拒绝
|
||||
|
||||
**症状**:
|
||||
```
|
||||
Permission denied: /usr/local/bin/gitlink-cli
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 方案1: 使用sudo
|
||||
sudo bash install.sh
|
||||
|
||||
# 方案2: 安装到用户目录
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
|
||||
INSTALL_DIR=$HOME/.local/bin bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题2: 命令未找到
|
||||
|
||||
**症状**:
|
||||
```
|
||||
bash: gitlink-cli: command not found
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 检查PATH
|
||||
echo $PATH | grep gitlink-cli
|
||||
|
||||
# 手动添加到PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# 永久添加
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题3: 下载失败
|
||||
|
||||
**症状**:
|
||||
```
|
||||
curl: (7) Failed to connect
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 检查网络连接
|
||||
curl -I https://www.gitlink.org.cn
|
||||
|
||||
# 使用代理
|
||||
export https_proxy=http://127.0.0.1:7890
|
||||
|
||||
# 手动下载安装
|
||||
# 1. 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
|
||||
# 2. 下载对应平台的压缩包
|
||||
# 3. 解压并添加到PATH
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题4: npm安装失败
|
||||
|
||||
**症状**:
|
||||
```
|
||||
npm ERR! EACCES
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 方案1: 修复npm权限
|
||||
mkdir -p ~/.npm-global
|
||||
npm config set prefix '~/.npm-global'
|
||||
echo 'export PATH="~/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 方案2: 使用sudo(不推荐)
|
||||
sudo npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题5: Skills未安装
|
||||
|
||||
**症状**:
|
||||
```
|
||||
Skills目录不存在或为空
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 手动安装Skills
|
||||
npx skills add https://www.gitlink.org.cn/Gitlink/gitlink-cli.git -y -g
|
||||
|
||||
# 或从本地安装
|
||||
npx skills add ./skills -y -g
|
||||
|
||||
# 使用专用命令
|
||||
gitlink-cli-install-skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 安装成功标准
|
||||
|
||||
### 检查清单
|
||||
|
||||
完成安装后,请验证以下内容:
|
||||
|
||||
- [ ] `gitlink-cli --version` 显示版本信息
|
||||
- [ ] `gitlink-cli auth status` 可查看登录状态
|
||||
- [ ] `gitlink-cli user +me` 可获取用户信息
|
||||
- [ ] `~/.gitlink/skills/` 目录包含13个Skills
|
||||
- [ ] 二进制文件在PATH中
|
||||
- [ ] 配置文件已创建
|
||||
|
||||
### 快速测试
|
||||
|
||||
```bash
|
||||
# 1. 查看版本
|
||||
gitlink-cli version
|
||||
|
||||
# 2. 配置
|
||||
gitlink-cli config init
|
||||
|
||||
# 3. 登录
|
||||
gitlink-cli auth login
|
||||
|
||||
# 4. 测试命令
|
||||
gitlink-cli user +me
|
||||
|
||||
# 5. 查看仓库列表
|
||||
gitlink-cli repo +list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新
|
||||
|
||||
### npm安装
|
||||
|
||||
```bash
|
||||
# 更新到最新版本
|
||||
npm update -g @gitlink-ai/cli
|
||||
|
||||
# 或重新安装
|
||||
npm uninstall -g @gitlink-ai/cli
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
### 脚本安装
|
||||
|
||||
```bash
|
||||
# 重新运行安装脚本(会覆盖旧版本)
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q1: 需要哪些系统权限?
|
||||
|
||||
**A**:
|
||||
- **一键脚本**: 需要`sudo`权限(安装到系统目录)
|
||||
- **npm**: 需要全局npm写入权限
|
||||
- **源码构建**: 需要Go环境和写入权限
|
||||
|
||||
### Q2: 可以安装多个版本吗?
|
||||
|
||||
**A**: 不建议。CLI工具通常会覆盖安装。如需多版本,可以使用Docker或版本管理工具。
|
||||
|
||||
### Q3: 离线环境如何安装?
|
||||
|
||||
**A**:
|
||||
```bash
|
||||
# 在在线环境下载完整包
|
||||
wget https://releases.gitlink.org.cn/gitlink-cli/gitlink-cli-full-v0.2.0.tar.gz
|
||||
|
||||
# 在离线环境安装
|
||||
tar -xzf gitlink-cli-full-v0.2.0.tar.gz
|
||||
cd gitlink-cli
|
||||
./install.sh --offline
|
||||
```
|
||||
|
||||
### Q4: 安装后如何配置默认编辑器?
|
||||
|
||||
**A**:
|
||||
```bash
|
||||
# 方法1: 配置文件
|
||||
vim ~/.config/gitlink-cli/config.yaml
|
||||
# 添加: editor: vim
|
||||
|
||||
# 方法2: 环境变量
|
||||
export EDITOR=vim
|
||||
|
||||
# 方法3: 命令行参数
|
||||
gitlink-cli issue +create --editor vim
|
||||
```
|
||||
|
||||
### Q5: Skills占多少空间?
|
||||
|
||||
**A**: Skills大约占用5-10MB空间,包含12个完整的AI Agent技能包。
|
||||
|
||||
---
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如有安装问题,请:
|
||||
|
||||
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
|
||||
2. 📖 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
|
||||
3. 💬 查看卸载指南: [doc/UNINSTALL.md](./UNINSTALL.md)
|
||||
4. 📧 联系支持: support@gitlink.org.cn
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
安装完成后,建议:
|
||||
|
||||
1. ✅ 运行 `gitlink-cli config init` 初始化配置
|
||||
2. ✅ 运行 `gitlink-cli auth login` 登录账号
|
||||
3. ✅ 查看 [README.md](../README.md) 了解基本使用
|
||||
4. ✅ 浏览 [Skills指南](../skills/README.md) 了解AI功能
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-04
|
||||
**相关文档**: [UNINSTALL.md](./UNINSTALL.md) | [README.md](../README.md)
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
# GitLink CLI 卸载指南
|
||||
|
||||
本文档介绍如何完全卸载 GitLink CLI 及其相关文件。
|
||||
|
||||
## 📋 卸载方式
|
||||
|
||||
### 一、Linux/macOS
|
||||
|
||||
#### 方式1: 使用卸载脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 交互式卸载(保留配置)
|
||||
bash uninstall.sh
|
||||
|
||||
# 完全删除所有文件(包括配置)
|
||||
bash uninstall.sh --purge
|
||||
|
||||
# 自动确认,不询问
|
||||
bash uninstall.sh -y
|
||||
```
|
||||
|
||||
#### 方式2: 在线执行
|
||||
|
||||
```bash
|
||||
# 保留配置
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash
|
||||
|
||||
# 完全删除
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash -s -- --purge
|
||||
```
|
||||
|
||||
#### 方式3: 手动删除
|
||||
|
||||
```bash
|
||||
# 删除二进制
|
||||
sudo rm -f /usr/local/bin/gitlink-cli
|
||||
# 或
|
||||
sudo rm -f /usr/bin/gitlink-cli
|
||||
|
||||
# 删除Skills
|
||||
rm -rf ~/.gitlink/skills
|
||||
|
||||
# 删除配置(可选)
|
||||
rm -rf ~/.gitlink-cli
|
||||
rm -rf ~/.gitlink
|
||||
rm -rf ~/.config/gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 二、Windows
|
||||
|
||||
#### 方式1: PowerShell 脚本(推荐)
|
||||
|
||||
```powershell
|
||||
# 交互式卸载
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1
|
||||
|
||||
# 完全删除
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 -Purge
|
||||
|
||||
# 自动确认
|
||||
.\uninstall.ps1 -Yes
|
||||
```
|
||||
|
||||
#### 方式2: 手动删除
|
||||
|
||||
```powershell
|
||||
# 删除二进制(根据安装位置)
|
||||
Remove-Item "$env:USERPROFILE\.gitlink-cli\bin\gitlink-cli.exe"
|
||||
|
||||
# 删除Skills
|
||||
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink\skills"
|
||||
|
||||
# 删除配置(可选)
|
||||
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink-cli"
|
||||
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 三、npm 安装
|
||||
|
||||
#### 方式1: npm 卸载(推荐)
|
||||
|
||||
```bash
|
||||
# 标准卸载(保留配置)
|
||||
npm uninstall -g @gitlink-ai/cli
|
||||
|
||||
# 完全删除(包括配置)
|
||||
npm uninstall -g @gitlink-ai/cli --purge
|
||||
```
|
||||
|
||||
#### 方式2: 使用卸载命令
|
||||
|
||||
```bash
|
||||
# 保留配置
|
||||
gitlink-cli-uninstall
|
||||
|
||||
# 完全删除
|
||||
gitlink-cli-uninstall --purge
|
||||
```
|
||||
|
||||
#### 方式3: 手动删除
|
||||
|
||||
```bash
|
||||
# 卸载npm包
|
||||
npm uninstall -g @gitlink-ai/cli
|
||||
|
||||
# 删除Skills
|
||||
rm -rf ~/.gitlink/skills
|
||||
|
||||
# 删除配置(可选)
|
||||
rm -rf ~/.gitlink-cli
|
||||
rm -rf ~/.gitlink
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 卸载选项说明
|
||||
|
||||
### `--purge` 参数
|
||||
|
||||
完全删除所有文件,包括:
|
||||
- ✅ 二进制文件
|
||||
- ✅ Skills目录
|
||||
- ✅ 配置文件
|
||||
- ✅ 用户数据
|
||||
- ✅ 缓存文件
|
||||
|
||||
### `--yes` / `-y` 参数
|
||||
|
||||
自动确认所有操作,不询问用户。
|
||||
|
||||
### 交互模式(默认)
|
||||
|
||||
卸载过程中会询问:
|
||||
1. 是否删除配置文件和数据
|
||||
2. npm安装时是否卸载npm包
|
||||
3. 确认卸载操作
|
||||
|
||||
---
|
||||
|
||||
## 📂 卸载内容清单
|
||||
|
||||
### 始终删除
|
||||
- ✅ 二进制文件 (`gitlink-cli` 或 `gitlink-cli.exe`)
|
||||
- ✅ Skills目录 (`~/.gitlink/skills/`)
|
||||
|
||||
### 条件删除(需要确认或 `--purge`)
|
||||
- 🔸 配置目录 (`~/.gitlink-cli/`)
|
||||
- 🔸 数据目录 (`~/.gitlink/`)
|
||||
- 🔸 配置文件 (`~/.config/gitlink-cli/`)
|
||||
- 🔸 npm全局包 (`@gitlink-ai/cli`)
|
||||
|
||||
### 保留文件
|
||||
- 📌 用户的项目文件
|
||||
- 📌 Git仓库
|
||||
- 📌 系统环境变量(需手动清理)
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理剩余文件
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 检查剩余文件
|
||||
find ~ -name "*gitlink*" -type f 2>/dev/null
|
||||
find ~ -name "*gitlink*" -type d 2>/dev/null
|
||||
|
||||
# 清理环境变量(如果手动添加过)
|
||||
# 编辑 ~/.bashrc, ~/.zshrc 等,删除相关行
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
# 检查剩余文件
|
||||
Get-ChildItem -Path $env:USERPROFILE -Recurse -Filter "*gitlink*" -ErrorAction SilentlyContinue
|
||||
|
||||
# 清理PATH环境变量
|
||||
# 1. 打开"系统属性" > "环境变量"
|
||||
# 2. 在"用户变量"或"系统变量"的Path中删除gitlink-cli路径
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q1: 卸载后命令仍然可用?
|
||||
|
||||
**A**: 可能是PATH缓存问题,解决方法:
|
||||
|
||||
**Linux/macOS**:
|
||||
```bash
|
||||
# 刷新shell
|
||||
hash -r gitlink-cli
|
||||
|
||||
# 或重启终端
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
```powershell
|
||||
# 重启PowerShell或CMD
|
||||
```
|
||||
|
||||
### Q2: 提示权限不足?
|
||||
|
||||
**A**: 使用管理员权限或删除到用户目录:
|
||||
|
||||
**Linux/macOS**:
|
||||
```bash
|
||||
# 使用sudo
|
||||
sudo bash uninstall.sh
|
||||
|
||||
# 或安装到用户目录
|
||||
INSTALL_DIR=$HOME/.local/bin bash uninstall.sh
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
```powershell
|
||||
# 以管理员身份运行PowerShell
|
||||
```
|
||||
|
||||
### Q3: 如何重新安装?
|
||||
|
||||
**A**: 重新运行安装脚本即可:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# Windows
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
# npm
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
### Q4: 配置文件会自动删除吗?
|
||||
|
||||
**A**: 不会,除非使用 `--purge` 参数或确认删除。这是为了保护用户数据。
|
||||
|
||||
### Q5: Skills会被删除吗?
|
||||
|
||||
**A**: 会的,Skills目录会被自动删除。如需保留,请手动备份:
|
||||
|
||||
```bash
|
||||
# 备份Skills
|
||||
cp -r ~/.gitlink/skills ~/gitlink-skills-backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 卸载脚本找不到
|
||||
|
||||
```bash
|
||||
# 确保在项目目录中
|
||||
cd /path/to/gitlink-cli
|
||||
|
||||
# 检查脚本是否存在
|
||||
ls -la uninstall.sh uninstall.ps1
|
||||
```
|
||||
|
||||
### 删除失败
|
||||
|
||||
```bash
|
||||
# 检查文件权限
|
||||
ls -la /usr/local/bin/gitlink-cli
|
||||
|
||||
# 强制删除
|
||||
sudo rm -f /usr/local/bin/gitlink-cli
|
||||
```
|
||||
|
||||
### npm卸载后仍有残留
|
||||
|
||||
```bash
|
||||
# 手动清理npm缓存
|
||||
npm cache clean --force
|
||||
|
||||
# 检查全局安装位置
|
||||
npm root -g
|
||||
npm list -g --depth=0
|
||||
|
||||
# 手动删除残留
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如有任何问题,请:
|
||||
|
||||
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
|
||||
2. 💬 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
|
||||
3. 📧 联系支持: support@gitlink.org.cn
|
||||
|
||||
---
|
||||
|
||||
## ✅ 卸载检查清单
|
||||
|
||||
完成卸载后,可以检查以下内容:
|
||||
|
||||
- [ ] 命令不可用(运行 `gitlink-cli --version` 应该报错)
|
||||
- [ ] 二进制文件已删除
|
||||
- [ ] Skills目录已删除
|
||||
- [ ] 配置文件已删除(如需要)
|
||||
- [ ] PATH环境变量已清理
|
||||
- [ ] 没有残留进程(`ps aux | grep gitlink` 或任务管理器)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-04
|
||||
**适用版本**: gitlink-cli v0.2.0+
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
<!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>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-subtle: #161b22;
|
||||
--bg-inset: #010409;
|
||||
--border: #30363d;
|
||||
--border-muted: #21262d;
|
||||
--text: #c9d1d9;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-soft: rgba(56,139,253,0.15);
|
||||
--green: #3fb950;
|
||||
--green-soft: rgba(63,185,80,0.12);
|
||||
--purple: #bc8cff;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", "Hiragino Sans GB", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(900px 380px at 100% -8%, rgba(56,139,253,0.10), transparent 60%),
|
||||
radial-gradient(800px 360px at -8% -4%, rgba(188,140,255,0.08), transparent 60%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(13,17,23,0.78);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding: 22px 24px 18px;
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-right: auto; }
|
||||
.logo {
|
||||
width: 44px; height: 44px; border-radius: 11px;
|
||||
background: linear-gradient(135deg, #2f81f7, #8957e5);
|
||||
display: grid; place-items: center;
|
||||
font-size: 24px; color: #fff; font-weight: 800;
|
||||
box-shadow: 0 4px 14px rgba(56,139,253,0.45);
|
||||
}
|
||||
.brand h1 { font-size: 20px; font-weight: 650; letter-spacing: -0.3px; }
|
||||
.brand .sub { font-size: 12.5px; color: var(--text-muted); }
|
||||
|
||||
.search-wrap { position: relative; flex: 0 1 320px; min-width: 200px; }
|
||||
.search-wrap svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-muted); }
|
||||
input#search {
|
||||
width: 100%; padding: 10px 14px 10px 38px;
|
||||
background: var(--bg-inset); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 9px;
|
||||
font-size: 14px; outline: none; transition: .15s;
|
||||
}
|
||||
input#search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.kbd {
|
||||
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* ---------- Stats ---------- */
|
||||
.stats {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding: 26px 24px 6px;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.stat::after {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.stat.green::after { background: var(--green); }
|
||||
.stat.purple::after { background: var(--purple); }
|
||||
.stat.muted::after { background: var(--text-muted); }
|
||||
.stat .num { font-size: 28px; font-weight: 700; letter-spacing: -1px; }
|
||||
.stat .label { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 22px 24px 64px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
|
||||
/* ---------- Category card ---------- */
|
||||
.card {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden; transition: border-color .15s;
|
||||
}
|
||||
.card:hover { border-color: var(--border); }
|
||||
.card-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 15px 18px; cursor: pointer; user-select: none;
|
||||
transition: background .12s;
|
||||
}
|
||||
.card-head:hover { background: rgba(177,186,196,0.04); }
|
||||
.card-icon { font-size: 20px; line-height: 1; }
|
||||
.card-title { font-size: 15px; font-weight: 600; flex: 1; }
|
||||
.count-badge {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
background: var(--bg-inset); border: 1px solid var(--border);
|
||||
padding: 2px 9px; border-radius: 20px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chev { color: var(--text-muted); transition: transform .2s; flex-shrink: 0; }
|
||||
.card.open .chev { transform: rotate(90deg); }
|
||||
|
||||
.card-body { max-height: 0; overflow: hidden; transition: max-height .25s ease; }
|
||||
.card.open .card-body { max-height: 2400px; }
|
||||
|
||||
.row {
|
||||
border-top: 1px solid var(--border-muted);
|
||||
padding: 11px 18px;
|
||||
cursor: pointer; transition: background .12s;
|
||||
}
|
||||
.row:hover { background: rgba(56,139,253,0.06); }
|
||||
.row-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cmd {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 13px; color: var(--accent); font-weight: 600;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: .3px;
|
||||
padding: 2px 7px; border-radius: 20px; white-space: nowrap;
|
||||
}
|
||||
.tag.auth { color: var(--green); background: var(--green-soft); }
|
||||
.tag.public { color: var(--accent); background: var(--accent-soft); }
|
||||
.desc { color: var(--text-muted); font-size: 13px; flex: 1; min-width: 120px; }
|
||||
|
||||
.example {
|
||||
max-height: 0; overflow: hidden; transition: max-height .2s ease;
|
||||
}
|
||||
.row.expanded .example { max-height: 140px; padding-top: 8px; }
|
||||
.example code {
|
||||
display: block;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 12px; color: var(--text);
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-muted); border-left: 3px solid var(--accent);
|
||||
border-radius: 7px; padding: 10px 12px;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---------- Empty / footer ---------- */
|
||||
#empty {
|
||||
text-align: center; padding: 60px 20px; color: var(--text-muted);
|
||||
display: none;
|
||||
}
|
||||
#empty .big { font-size: 40px; margin-bottom: 10px; }
|
||||
footer {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding: 0 24px 40px; text-align: center;
|
||||
color: var(--text-muted); font-size: 12.5px;
|
||||
}
|
||||
footer a { color: var(--accent); text-decoration: none; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.search-wrap { flex: 1 1 100%; }
|
||||
.header-inner { gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<div class="logo">G</div>
|
||||
<div>
|
||||
<h1>gitlink-cli · 功能全景</h1>
|
||||
<div class="sub">交互式命令浏览仪表盘</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>
|
||||
<input id="search" type="text" placeholder="搜索命令或描述… ( 按 / 聚焦 )" autocomplete="off">
|
||||
<span class="kbd">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat"><div class="num" id="st-cat">0</div><div class="label">功能分类</div></div>
|
||||
<div class="stat purple"><div class="num" id="st-total">0</div><div class="label">Shortcuts 总数</div></div>
|
||||
<div class="stat green"><div class="num" id="st-auth">0</div><div class="label">需认证命令</div></div>
|
||||
<div class="stat muted"><div class="num" id="st-public">0</div><div class="label">公开命令</div></div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div id="empty">
|
||||
<div class="big">🔍</div>
|
||||
<div>没有匹配的命令,换个关键词试试。</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Generated by <strong>gitlink-code-insight</strong> skill ·
|
||||
<a href="https://gitlink.org.cn" target="_blank" rel="noopener">Gitlink</a> ·
|
||||
共 <span id="ft-total">0</span> 个命令
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const DATA = [
|
||||
{ cat: "仓库管理", icon: "📦", items: [
|
||||
{ cmd: "repo +list", desc: "仓库列表", auth: false, ex: "gitlink-cli repo +list --user zhangsan" },
|
||||
{ cmd: "repo +info", desc: "仓库详情", auth: false, ex: "gitlink-cli repo +info --owner Gitlink --repo forgeplus" },
|
||||
{ cmd: "repo +create", desc: "创建仓库", auth: true, ex: 'gitlink-cli repo +create --name my-project --description "项目描述"' },
|
||||
{ cmd: "repo +fork", desc: "Fork 仓库", auth: true, ex: "gitlink-cli repo +fork --owner Gitlink --repo forgeplus" },
|
||||
{ cmd: "repo +delete", desc: "删除仓库(不可逆)", auth: true, ex: "gitlink-cli repo +delete --owner myuser --repo old-project" },
|
||||
{ cmd: "repo +batch-create", desc: "批量创建仓库", auth: true, ex: "gitlink-cli repo +batch-create --from repos.csv" },
|
||||
{ cmd: "repo +batch-update", desc: "批量更新仓库", auth: true, ex: "gitlink-cli repo +batch-update --from updates.csv" },
|
||||
{ cmd: "repo +add-member", desc: "添加仓库成员", auth: true, ex: "gitlink-cli repo +add-member --owner myuser --repo myrepo --user newmember --role developer" },
|
||||
]},
|
||||
{ cat: "分支管理", icon: "🌿", items: [
|
||||
{ cmd: "branch +list", desc: "分支列表", auth: false, ex: "gitlink-cli branch +list --owner Gitlink --repo forgeplus" },
|
||||
{ cmd: "branch +create", desc: "创建分支", auth: true, ex: "gitlink-cli branch +create --name feature/new-feature" },
|
||||
{ cmd: "branch +delete", desc: "删除分支(不可逆)", auth: true, ex: "gitlink-cli branch +delete --name feature/old-feature" },
|
||||
{ cmd: "branch +protect", desc: "保护分支", auth: true, ex: "gitlink-cli branch +protect --name main" },
|
||||
{ cmd: "branch +unprotect", desc: "取消保护", auth: true, ex: "gitlink-cli branch +unprotect --name main" },
|
||||
]},
|
||||
{ cat: "Issue 管理", icon: "🐛", items: [
|
||||
{ cmd: "issue +list", desc: "Issue 列表", auth: false, ex: "gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open" },
|
||||
{ cmd: "issue +view", desc: "Issue 详情", auth: false, ex: "gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4" },
|
||||
{ cmd: "issue +create", desc: "创建 Issue", auth: true, ex: 'gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤"' },
|
||||
{ cmd: "issue +update", desc: "更新 Issue", auth: true, ex: 'gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"' },
|
||||
{ cmd: "issue +close", desc: "关闭 Issue", auth: true, ex: "gitlink-cli issue +close --number 4" },
|
||||
{ cmd: "issue +batch-close", desc: "批量关闭 Issue", auth: true, ex: "gitlink-cli issue +batch-close --numbers 123,124 --dry-run" },
|
||||
{ cmd: "issue +comment", desc: "添加评论", auth: true, ex: 'gitlink-cli issue +comment --number 4 --body "已修复"' },
|
||||
]},
|
||||
{ cat: "Pull Request", icon: "🔀", items: [
|
||||
{ cmd: "pr +list", desc: "PR 列表", auth: false, ex: "gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open" },
|
||||
{ cmd: "pr +view", desc: "PR 详情", auth: false, ex: "gitlink-cli pr +view --id 3" },
|
||||
{ cmd: "pr +create", desc: "创建 PR", auth: true, ex: 'gitlink-cli pr +create --title "feat: 新功能" --head feature/x --base master' },
|
||||
{ cmd: "pr +merge", desc: "合并 PR", auth: true, ex: "gitlink-cli pr +merge --id 3 --method squash" },
|
||||
{ cmd: "pr +close", desc: "关闭 PR", auth: true, ex: "gitlink-cli pr +close --id 3" },
|
||||
{ cmd: "pr +files", desc: "变更文件列表", auth: false, ex: "gitlink-cli pr +files --id 3" },
|
||||
{ cmd: "pr +diff", desc: "查看提交列表", auth: false, ex: "gitlink-cli pr +diff --id 3" },
|
||||
{ cmd: "pr +comment", desc: "PR 评论", auth: true, ex: 'gitlink-cli pr +comment --id 3 --body "LGTM"' },
|
||||
{ cmd: "pr +review", desc: "代码审查", auth: true, ex: 'gitlink-cli pr +review --id 3 --event COMMENT --body "整体 LGTM"' },
|
||||
]},
|
||||
{ cat: "版本发布", icon: "🚀", items: [
|
||||
{ cmd: "release +list", desc: "发布列表", auth: false, ex: "gitlink-cli release +list --owner Gitlink --repo forgeplus" },
|
||||
{ cmd: "release +view", desc: "发布详情", auth: false, ex: "gitlink-cli release +view --id <version_id>" },
|
||||
{ cmd: "release +create", desc: "创建发布", auth: true, ex: 'gitlink-cli release +create --tag v1.0.0 --name "v1.0.0" --target master' },
|
||||
{ cmd: "release +delete", desc: "删除发布(不可逆)", auth: true, ex: "gitlink-cli release +delete --id <version_id>" },
|
||||
]},
|
||||
{ cat: "Wiki 管理", icon: "📖", items: [
|
||||
{ cmd: "wiki +list", desc: "Wiki 页面列表", auth: false, ex: "gitlink-cli wiki +list --owner Gitlink --repo forgeplus" },
|
||||
{ cmd: "wiki +view", desc: "查看页面内容", auth: false, ex: 'gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home"' },
|
||||
{ cmd: "wiki +create", desc: "创建页面", auth: true, ex: 'gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md' },
|
||||
{ cmd: "wiki +update", desc: "更新页面", auth: true, ex: 'gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "新内容"' },
|
||||
{ cmd: "wiki +delete", desc: "删除页面", auth: true, ex: 'gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面"' },
|
||||
]},
|
||||
{ cat: "CI/CD", icon: "⚙️", items: [
|
||||
{ cmd: "ci +builds", desc: "构建列表", auth: true, ex: "gitlink-cli ci +builds --owner myuser --repo myrepo" },
|
||||
{ cmd: "ci +logs", desc: "构建日志", auth: true, ex: "gitlink-cli ci +logs --build 42 --stage 1 --step 1" },
|
||||
{ cmd: "ci +restart", desc: "重启构建", auth: true, ex: "gitlink-cli ci +restart --build 42" },
|
||||
{ cmd: "ci +stop", desc: "停止构建", auth: true, ex: "gitlink-cli ci +stop --build 42" },
|
||||
]},
|
||||
{ cat: "Webhook", icon: "🔔", items: [
|
||||
{ cmd: "webhook +list", desc: "Webhook 列表", auth: true, ex: "gitlink-cli webhook +list --owner myuser --repo myrepo" },
|
||||
{ cmd: "webhook +info", desc: "Webhook 详情", auth: true, ex: "gitlink-cli webhook +info --owner myuser --repo myrepo --id 123" },
|
||||
{ cmd: "webhook +events", desc: "支持的事件类型", auth: false, ex: "gitlink-cli webhook +events" },
|
||||
{ cmd: "webhook +create", desc: "创建 Webhook", auth: true, ex: "gitlink-cli webhook +create --url https://example.com/hook --events push" },
|
||||
{ cmd: "webhook +update", desc: "更新 Webhook", auth: true, ex: "gitlink-cli webhook +update --id 123 --events push,pull_request" },
|
||||
{ cmd: "webhook +test", desc: "测试 Webhook", auth: true, ex: "gitlink-cli webhook +test --id 123 --event push" },
|
||||
{ cmd: "webhook +delete", desc: "删除 Webhook", auth: true, ex: "gitlink-cli webhook +delete --id 123" },
|
||||
]},
|
||||
{ cat: "组织管理", icon: "🏢", items: [
|
||||
{ cmd: "org +list", desc: "组织列表", auth: false, ex: "gitlink-cli org +list" },
|
||||
{ cmd: "org +info", desc: "组织详情", auth: false, ex: "gitlink-cli org +info --id Gitlink" },
|
||||
{ cmd: "org +members", desc: "成员列表", auth: false, ex: "gitlink-cli org +members --id Gitlink" },
|
||||
{ cmd: "org +create", desc: "创建组织", auth: true, ex: 'gitlink-cli org +create --name my-org --description "我的组织"' },
|
||||
{ cmd: "org +batch-add", desc: "批量添加成员", auth: true, ex: 'gitlink-cli org +batch-add --id my-org --users "user1,user2"' },
|
||||
]},
|
||||
{ cat: "用户与搜索", icon: "👤", items: [
|
||||
{ cmd: "user +me", desc: "当前登录用户", auth: true, ex: "gitlink-cli user +me" },
|
||||
{ cmd: "user +info", desc: "用户详情", auth: false, ex: "gitlink-cli user +info --login zhangsan" },
|
||||
{ cmd: "search +repos", desc: "搜索仓库", auth: false, ex: 'gitlink-cli search +repos --keyword "machine learning"' },
|
||||
{ cmd: "search +users", desc: "搜索用户", auth: false, ex: 'gitlink-cli search +users --keyword "zhangsan"' },
|
||||
]},
|
||||
{ cat: "安全与合规", icon: "🛡️", items: [
|
||||
{ cmd: "compliance +scan", desc: "全量扫描", auth: false, ex: "gitlink-cli compliance +scan" },
|
||||
{ cmd: "compliance +license", desc: "许可证合规检查", auth: false, ex: "gitlink-cli compliance +license" },
|
||||
{ cmd: "compliance +deps", desc: "依赖许可证检查", auth: false, ex: "gitlink-cli compliance +deps" },
|
||||
{ cmd: "compliance +secrets", desc: "敏感信息扫描", auth: false, ex: "gitlink-cli compliance +secrets" },
|
||||
{ cmd: "compliance +exposure", desc: "PII 与暴露面扫描", auth: false, ex: "gitlink-cli compliance +exposure" },
|
||||
{ cmd: "compliance +vocab", desc: "敏感词汇扫描", auth: false, ex: "gitlink-cli compliance +vocab" },
|
||||
]},
|
||||
{ cat: "新人引导", icon: "👋", items: [
|
||||
{ cmd: "onboard +welcome", desc: "添加引导评论", auth: true, ex: 'gitlink-cli onboard +welcome --issues "3,7,15"' },
|
||||
]},
|
||||
{ cat: "团队管理", icon: "👥", items: [
|
||||
{ cmd: "team +list", desc: "团队列表", auth: false, ex: "gitlink-cli team +list --org my-org" },
|
||||
{ cmd: "team +create", desc: "创建团队", auth: true, ex: "gitlink-cli team +create --org my-org --name dev-team" },
|
||||
{ cmd: "team +add-member", desc: "添加成员", auth: true, ex: "gitlink-cli team +add-member --org my-org --team dev-team --user newmember" },
|
||||
]},
|
||||
{ cat: "贡献报告", icon: "📊", items: [
|
||||
{ cmd: "contrib +report", desc: "贡献统计报告", auth: false, ex: "gitlink-cli contrib +report --owner myuser --repo myrepo" },
|
||||
]},
|
||||
];
|
||||
|
||||
const esc = s => s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
const grid = document.getElementById("grid");
|
||||
const empty = document.getElementById("empty");
|
||||
|
||||
// 全量统计(不随搜索变化)
|
||||
const TOTAL = DATA.reduce((n, c) => n + c.items.length, 0);
|
||||
const AUTHN = DATA.reduce((n, c) => n + c.items.filter(i => i.auth).length, 0);
|
||||
const PUBLIC = TOTAL - AUTHN;
|
||||
document.getElementById("st-cat").textContent = DATA.length;
|
||||
document.getElementById("st-total").textContent = TOTAL;
|
||||
document.getElementById("st-auth").textContent = AUTHN;
|
||||
document.getElementById("st-public").textContent = PUBLIC;
|
||||
document.getElementById("ft-total").textContent = TOTAL;
|
||||
|
||||
function render(filter = "") {
|
||||
const q = filter.trim().toLowerCase();
|
||||
grid.innerHTML = "";
|
||||
let shownCats = 0;
|
||||
|
||||
DATA.forEach(c => {
|
||||
const matched = c.items.filter(it =>
|
||||
!q || it.cmd.toLowerCase().includes(q) || it.desc.toLowerCase().includes(q) || c.cat.toLowerCase().includes(q)
|
||||
);
|
||||
if (!matched.length) return;
|
||||
shownCats++;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "card" + (q ? " open" : ""); // 搜索时自动展开
|
||||
card.innerHTML = `
|
||||
<div class="card-head">
|
||||
<span class="card-icon">${c.icon}</span>
|
||||
<span class="card-title">${c.cat}</span>
|
||||
<span class="count-badge">${matched.length}</span>
|
||||
<svg class="chev" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${matched.map(it => `
|
||||
<div class="row">
|
||||
<div class="row-main">
|
||||
<span class="cmd">${esc(it.cmd)}</span>
|
||||
<span class="tag ${it.auth ? "auth" : "public"}">${it.auth ? "需认证" : "公开"}</span>
|
||||
<span class="desc">${esc(it.desc)}</span>
|
||||
</div>
|
||||
<div class="example"><code>$ ${esc(it.ex)}</code></div>
|
||||
</div>`).join("")}
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
empty.style.display = shownCats === 0 ? "block" : "none";
|
||||
|
||||
// 绑定交互
|
||||
grid.querySelectorAll(".card-head").forEach(h =>
|
||||
h.addEventListener("click", () => h.parentElement.classList.toggle("open")));
|
||||
grid.querySelectorAll(".row").forEach(r =>
|
||||
r.addEventListener("click", e => { e.stopPropagation(); r.classList.toggle("expanded"); }));
|
||||
}
|
||||
|
||||
// 搜索(带去抖)
|
||||
const input = document.getElementById("search");
|
||||
let t;
|
||||
input.addEventListener("input", e => { clearTimeout(t); t = setTimeout(() => render(e.target.value), 80); });
|
||||
|
||||
// 快捷键
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "/" && document.activeElement !== input) { e.preventDefault(); input.focus(); }
|
||||
if (e.key === "Escape") { input.value = ""; render(""); input.blur(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
232
doc/design.md
232
doc/design.md
|
|
@ -40,28 +40,33 @@ gitlink-cli/
|
|||
│ ├── common/
|
||||
│ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义
|
||||
│ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo
|
||||
│ ├── repo/ # repo +create / +clone / +fork / +list / +info
|
||||
│ ├── issue/ # issue +list / +create / +view / +close / +comment
|
||||
│ ├── pr/ # pr +list / +create / +view / +merge / +review
|
||||
│ ├── release/ # release +list / +create / +download
|
||||
│ ├── branch/ # branch +list / +protect / +unprotect
|
||||
│ ├── org/ # org +list / +info / +members
|
||||
│ ├── repo/ # repo +create / +clone / +fork / +list / +info / +delete / +settings / +batch-create / +batch-update
|
||||
│ ├── issue/ # issue +list / +create / +view / +update / +close / +comment / +assign / +label / +batch-* (6 个批量命令)
|
||||
│ ├── wiki/ # wiki +list / +view / +create / +update / +delete
|
||||
│ ├── pr/ # pr +list / +create / +view / +merge / +close / +review / +files / +diff
|
||||
│ ├── release/ # release +list / +create / +view / +delete / +download
|
||||
│ ├── branch/ # branch +list / +create / +delete / +protect / +unprotect
|
||||
│ ├── webhook/ # webhook +list / +create / +update / +delete / +test / +info
|
||||
│ ├── org/ # org +list / +info / +members / +create
|
||||
│ ├── user/ # user +me / +info
|
||||
│ ├── search/ # search +repos / +issues / +users
|
||||
│ ├── ci/ # ci +builds / +logs / +restart
|
||||
│ ├── ci/ # ci +builds / +logs / +restart / +stop
|
||||
│ └── register.go # 注册所有 shortcuts 到 cobra
|
||||
├── skills/
|
||||
│ ├── gitlink-shared/ # SKILL.md — 认证、全局参数、安全规则
|
||||
│ ├── gitlink-repo/ # SKILL.md + references/ — 仓库操作
|
||||
│ ├── gitlink-issue/ # SKILL.md + references/ — Issue 操作
|
||||
│ ├── gitlink-pr/ # SKILL.md + references/ — PR 操作
|
||||
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
|
||||
│ ├── gitlink-branch/ # SKILL.md + references/ — 分支操作
|
||||
│ ├── gitlink-ci/ # SKILL.md + references/ — CI/CD 操作
|
||||
│ ├── gitlink-org/ # SKILL.md + references/ — 组织管理
|
||||
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
|
||||
│ ├── gitlink-search/ # SKILL.md + references/ — 搜索
|
||||
│ ├── gitlink-user/ # SKILL.md + references/ — 用户管理
|
||||
│ ├── gitlink-pm/ # SKILL.md + references/ — 项目管理
|
||||
│ └── gitlink-workflow/ # SKILL.md — AI 自动化工作流(Issue 分类、PR Review 等)
|
||||
│ ├── gitlink-wiki/ # SKILL.md + references/ + examples/ — Wiki 操作
|
||||
│ ├── gitlink-webhook/ # SKILL.md + references/ + examples/ — Webhook 管理
|
||||
│ └── gitlink-workflow/ # SKILL.md + references/ + examples/ — AI 自动化工作流(Issue 分类、PR Review 等)
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Makefile
|
||||
|
|
@ -74,18 +79,20 @@ gitlink-cli/
|
|||
|
||||
### 2.1 Layer 1: Shortcuts(快捷命令,`+` 前缀)
|
||||
|
||||
面向高频场景的语义化封装,MVP 覆盖 ~43 个:
|
||||
面向高频场景的语义化封装,覆盖 13 个领域共 62 个命令:
|
||||
|
||||
| 领域 | Shortcuts | 数量 |
|
||||
|------|-----------|------|
|
||||
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 |
|
||||
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 |
|
||||
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` `+batch-create` `+batch-update` | 9 |
|
||||
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` `+batch-close` `+batch-status` `+batch-priority` `+batch-assign` `+batch-label` `+batch-create` | 14 |
|
||||
| wiki | `+list` `+view` `+create` `+update` `+delete` | 5 |
|
||||
| pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 |
|
||||
| release | `+list` `+create` `+view` `+delete` `+download` | 5 |
|
||||
| branch | `+list` `+create` `+delete` `+protect` `+unprotect` | 5 |
|
||||
| org | `+list` `+info` `+members` `+create` | 4 |
|
||||
| ci | `+builds` `+logs` `+restart` `+stop` | 4 |
|
||||
| user | `+me` `+info` | 2 |
|
||||
| webhook | `+list` `+create` `+update` `+delete` `+test` `+info` | 6 |
|
||||
|
||||
**Shortcut 声明式定义**:
|
||||
|
||||
|
|
@ -437,7 +444,169 @@ skills/
|
|||
|
||||
---
|
||||
|
||||
## 9 完整命令参考
|
||||
## 9 批量操作设计模式
|
||||
|
||||
Issue 和 Repo 两个领域均实现了批量操作命令,遵循统一的设计模式。
|
||||
|
||||
### 9.1 命令清单
|
||||
|
||||
| 领域 | 命令 | 用途 | 输入方式 |
|
||||
|------|------|------|----------|
|
||||
| issue | `+batch-close` | 批量关闭 | `--numbers` 或 `--from` CSV |
|
||||
| issue | `+batch-status` | 批量更换状态 | `--state` + `--numbers`/`--from` |
|
||||
| issue | `+batch-priority` | 批量更换优先级 | `--priority` + `--numbers`/`--from` |
|
||||
| issue | `+batch-assign` | 批量更换负责人 | `--assignee` + `--numbers`/`--from` |
|
||||
| issue | `+batch-label` | 批量更换标记 | `--label` + `--numbers`/`--from` |
|
||||
| issue | `+batch-create` | 批量创建 Issue | `--titles` 或 `--from` CSV(支持 Bug/Feature 模板) |
|
||||
| repo | `+batch-create` | 批量创建仓库 | `--names` 或 `--from` CSV |
|
||||
| repo | `+batch-update` | 批量更新仓库 | `--names` 或 `--from` CSV |
|
||||
|
||||
### 9.2 核心类型
|
||||
|
||||
```go
|
||||
type BatchResult struct {
|
||||
Number string `json:"number"` // Issue 编号或仓库名
|
||||
Action string `json:"action"` // 操作类型
|
||||
Status string `json:"status"` // 执行结果
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type BatchSummary struct {
|
||||
Repository string `json:"repository"`
|
||||
Action string `json:"action"`
|
||||
Value string `json:"value,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Results []BatchResult `json:"results"`
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 统一设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| **输入灵活** | `--numbers`/`--names`(内联逗号分隔)和 `--from`(CSV 文件)可同时使用,自动去重合并 |
|
||||
| **dry-run 统一** | 所有批量命令支持 `--dry-run`,预览模式下 status 为 `"planned"`,不发起写请求 |
|
||||
| **错误不中断** | 单条失败不影响后续处理,全部执行完后返回完整汇总。有任何失败则 exit code = 1 |
|
||||
| **输出统一** | 所有命令输出相同结构的 `BatchSummary` JSON |
|
||||
| **修改前先 GET** | Issue 批量修改先 GET 当前 Issue 保留 subject/description,PATCH 时只替换目标字段。Repo 批量更新先 GET 获取 name + identifier |
|
||||
| **参数容错** | 所有名称映射(状态、优先级、标签、负责人)同时支持字符串名和直接传数字 ID |
|
||||
|
||||
### 9.4 Issue 参数值映射
|
||||
|
||||
**状态**:`new`(1) / `in-progress`(2) / `resolved`(3) / `closed`(5) / `rejected`(6)
|
||||
|
||||
**优先级**:`low`(1) / `normal`(2) / `high`(3) / `urgent`(4)
|
||||
|
||||
**标记**:使用项目级中文标签名映射到大整数 ID(如 `缺陷`→315526、`功能`→315527)
|
||||
|
||||
**负责人**:传入 login 用户名,CLI 调用 `/users/{login}` API 转换为 `user_id`
|
||||
|
||||
### 9.5 Issue 批量创建模板系统
|
||||
|
||||
`+batch-create` 支持三种输入模式:
|
||||
|
||||
1. **CLI 直接输入**(`--titles`):逗号分隔标题,统一应用 `--priority`/`--label`/`--assignee`/`--state`
|
||||
2. **自由 CSV**(`--from`):自由指定 title/body/priority/label/assignee/status 列
|
||||
3. **模板 CSV**(`--from` + `--template`):
|
||||
- `--template bug`:自动生成 Bug 描述格式,自动设置缺陷标签
|
||||
- `--template feature`:自动生成功能描述格式,自动设置功能标签
|
||||
|
||||
### 9.6 Repo 批量操作注意事项
|
||||
|
||||
- **batch-create**:POST 路径 `/{login}/{name}` 中的 login 必须是当前登录用户,需先 `GET /users/me`
|
||||
- **batch-update**:PATCH 请求体必须包含从 GET 获取的 `name` 和 `identifier`,否则 API 报错
|
||||
- **--private/--public 互斥**:batch-update 不允许同时设置两个标志
|
||||
|
||||
### 9.7 关键 API 字段差异
|
||||
|
||||
GitLink 基于 Redmine 但修改了大量字段名:
|
||||
|
||||
| Redmine 标准字段 | GitLink 实际字段 | 格式 |
|
||||
|-----------------|-----------------|------|
|
||||
| `assigned_to_id` | `assigner_ids` | 数组 `[user_id]` |
|
||||
| `tracker_id` | `issue_tag_ids` | 数组 `[tag_id]`(项目级大整数) |
|
||||
|
||||
> GitLink API 对不认识的字段返回 200 而非报错,字段名错误会导致静默失败。必须通过浏览器 DevTools 抓取实际请求确认字段名和格式。
|
||||
|
||||
---
|
||||
|
||||
## 10 Wiki Shortcut 设计
|
||||
|
||||
Wiki 是独立的全新 Shortcut 领域,提供 5 个命令覆盖 Wiki 页面的 CRUD 操作。
|
||||
|
||||
### 10.1 双域名架构
|
||||
|
||||
Wiki API 部署在 gateway 域名上,与主站 API(www 域名)分离:
|
||||
|
||||
```
|
||||
┌─ www 域名 ─────────────────────┐
|
||||
resolveProjectID() → │ GET /{owner}/{repo}/detail.json │ → project_id
|
||||
└────────────────────────────────┘
|
||||
|
||||
┌─ gateway 域名 ───────────────────────────┐
|
||||
callWikiAPI*() → │ /wiki/open/* (不带 .json) │ → 解包 code/data
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **www 域名**(`https://www.gitlink.org.cn/api`):用于 detail API 获取 project_id,走默认 client
|
||||
- **gateway 域名**(`https://gateway.gitlink.org.cn/api`):用于所有 wiki CRUD API,走独立 client
|
||||
|
||||
### 10.2 Client 扩展
|
||||
|
||||
`internal/client/client.go` 新增 `SkipJSONSuffix` 字段:
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
// ... 原有字段 ...
|
||||
SkipJSONSuffix bool // 为 true 时不自动追加 .json 后缀(wiki gateway API 需要)
|
||||
}
|
||||
```
|
||||
|
||||
wiki 命令创建独立 client 实例,设置 `SkipJSONSuffix: true` 并使用 gateway BaseURL。
|
||||
|
||||
### 10.3 响应解包
|
||||
|
||||
Gateway API 使用不同的响应格式 `{code, data, msg}`(而非常规的 `{status, ...}`):
|
||||
|
||||
```go
|
||||
// unwrapGatewayResponse 解包 gateway 响应
|
||||
// 成功: code=200/201, 提取 data 字段
|
||||
// 失败: code=500/400/404, 返回 "[code] msg" 错误信息
|
||||
func unwrapGatewayResponse(raw []byte) ([]byte, error)
|
||||
```
|
||||
|
||||
### 10.4 命令详情
|
||||
|
||||
| 命令 | HTTP 方法 | API 路径 | 关键参数 |
|
||||
|------|----------|---------|---------|
|
||||
| `wiki +list` | GET | `/wiki/open/wikiPages` | 无额外参数 |
|
||||
| `wiki +view` | GET | `/wiki/open/getWiki` | `--title`(必填) |
|
||||
| `wiki +create` | POST | `/wiki/open/createWiki` | `--title`(必填)`--content/--file` `--message` |
|
||||
| `wiki +update` | PUT | `/wiki/open/updateWiki` | `--title`(必填)`--cover/--add` `--file` |
|
||||
| `wiki +delete` | DELETE | `/wiki/open/deleteWiki` | `--title`(必填) |
|
||||
|
||||
### 10.5 特殊处理
|
||||
|
||||
| 处理项 | 说明 |
|
||||
|--------|------|
|
||||
| **base64 编解码** | Wiki 内容在 API 中为 base64 编码,CLI 自动编解码,对用户透明 |
|
||||
| **project_id 缓存** | `resolveProjectID()` 使用 `sync.Map` 缓存,同一 owner/repo 只调一次 API |
|
||||
| **owner/repo 自动解析** | 在 git 仓库目录下可省略 `--owner`/`--repo` |
|
||||
| **--update --add 模式** | 先 GET 现有内容 → 解码 → 追加 → 重新编码 → PUT 提交 |
|
||||
| **嵌套对象过滤** | 表格输出时过滤无意义的嵌套对象字段(如 wiki_clone_link) |
|
||||
|
||||
### 10.6 已知限制
|
||||
|
||||
- **delete 后端 bug**:GitLink 平台 `deleteWiki` API 只清空内容,不删除侧边栏条目
|
||||
- **gateway 域名硬编码**:wiki API 仅部署在 gateway,detail API 在 www,不可互换
|
||||
- **create/update pageName 差异**:create 接受原始中文 pageName,update 需要 URL 编码
|
||||
|
||||
---
|
||||
|
||||
## 11 完整命令参考
|
||||
|
||||
```
|
||||
gitlink-cli
|
||||
|
|
@ -457,7 +626,9 @@ gitlink-cli
|
|||
│ ├── +list # 仓库列表
|
||||
│ ├── +info # 仓库详情
|
||||
│ ├── +delete # 删除仓库
|
||||
│ └── +settings # 仓库设置
|
||||
│ ├── +settings # 仓库设置
|
||||
│ ├── +batch-create # 批量创建仓库
|
||||
│ └── +batch-update # 批量更新仓库
|
||||
├── issue
|
||||
│ ├── +list # Issue 列表
|
||||
│ ├── +create # 创建 Issue
|
||||
|
|
@ -466,7 +637,13 @@ gitlink-cli
|
|||
│ ├── +close # 关闭 Issue
|
||||
│ ├── +comment # 添加评论
|
||||
│ ├── +assign # 指派
|
||||
│ └── +label # 标签管理
|
||||
│ ├── +label # 标签管理
|
||||
│ ├── +batch-close # 批量关闭 Issue
|
||||
│ ├── +batch-status # 批量更换状态
|
||||
│ ├── +batch-priority # 批量更换优先级
|
||||
│ ├── +batch-assign # 批量更换负责人
|
||||
│ ├── +batch-label # 批量更换标记
|
||||
│ └── +batch-create # 批量创建 Issue(含 Bug/Feature 模板)
|
||||
├── pr
|
||||
│ ├── +list # PR 列表
|
||||
│ ├── +create # 创建 PR
|
||||
|
|
@ -501,6 +678,19 @@ gitlink-cli
|
|||
├── user
|
||||
│ ├── +me # 当前用户
|
||||
│ └── +info # 用户详情
|
||||
├── webhook
|
||||
│ ├── +list # Webhook 列表
|
||||
│ ├── +create # 创建 Webhook
|
||||
│ ├── +update # 更新 Webhook
|
||||
│ ├── +delete # 删除 Webhook
|
||||
│ ├── +test # 测试 Webhook
|
||||
│ └── +info # Webhook 详情
|
||||
├── wiki
|
||||
│ ├── +list # Wiki 页面列表
|
||||
│ ├── +view # 查看 Wiki 页面
|
||||
│ ├── +create # 创建 Wiki 页面
|
||||
│ ├── +update # 更新 Wiki 页面
|
||||
│ └── +delete # 删除 Wiki 页面
|
||||
├── search
|
||||
│ ├── +repos # 搜索仓库
|
||||
│ ├── +issues # 搜索 Issue
|
||||
|
|
@ -518,7 +708,7 @@ gitlink-cli
|
|||
|
||||
---
|
||||
|
||||
## 10 关键文件清单
|
||||
## 12 关键文件清单
|
||||
|
||||
实现时需要修改/创建的核心文件:
|
||||
|
||||
|
|
@ -541,8 +731,10 @@ gitlink-cli
|
|||
| `internal/registry/meta_data.json` | API 元数据 |
|
||||
| `shortcuts/common/types.go` | Shortcut 核心类型 |
|
||||
| `shortcuts/common/runner.go` | RuntimeContext |
|
||||
| `shortcuts/repo/*.go` | 仓库 shortcuts |
|
||||
| `shortcuts/issue/*.go` | Issue shortcuts |
|
||||
| `shortcuts/repo/*.go` | 仓库 shortcuts(含 batch_create/batch_update) |
|
||||
| `shortcuts/issue/*.go` | Issue shortcuts(含 batch.go + batch_create.go 批量操作) |
|
||||
| `shortcuts/wiki/*.go` | Wiki shortcuts(list, view, create, update, delete) |
|
||||
| `shortcuts/webhook/*.go` | Webhook shortcuts(list, create, update, delete, test, info) |
|
||||
| `shortcuts/pr/*.go` | PR shortcuts |
|
||||
| `shortcuts/register.go` | Shortcut 注册 |
|
||||
| `skills/gitlink-shared/SKILL.md` | 共享 Skill |
|
||||
|
|
@ -550,7 +742,7 @@ gitlink-cli
|
|||
|
||||
---
|
||||
|
||||
## 11 开发计划
|
||||
## 13 开发计划
|
||||
|
||||
### Phase 1: Foundation(第 1-2 周)
|
||||
|
||||
|
|
@ -609,7 +801,7 @@ gitlink-cli
|
|||
|
||||
---
|
||||
|
||||
## 12 验证方案
|
||||
## 14 验证方案
|
||||
|
||||
| 阶段 | 验证方式 |
|
||||
|------|----------|
|
||||
|
|
|
|||
|
|
@ -23,6 +23,159 @@
|
|||
|
||||
- HTTP Authentication, scheme: bearer
|
||||
|
||||
---
|
||||
|
||||
# GitLink API 使用注意事项
|
||||
|
||||
> **重要提示**: 以下注意事项基于实际使用经验总结,使用GitLink API时请特别注意这些行为和限制。
|
||||
|
||||
## 已知问题和特殊行为
|
||||
|
||||
### API响应格式
|
||||
| 问题 | 说明 | 影响 | 解决方案 |
|
||||
|------|------|------|----------|
|
||||
| **双重错误码** | HTTP 200 + body.status 非200 | 错误判断复杂 | 需要检查HTTP状态码和body.status |
|
||||
| **错误格式不一致** | 有`{status, message}`也有`{code, msg}` | 错误解析困难 | 兼容处理两种格式 |
|
||||
| **静默失败** | 字段名错误可能返回200但不生效 | 调试困难 | 通过GET验证实际修改 |
|
||||
|
||||
### Issue API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| Issue创建 | 必须包含`done_ratio: 0`,否则数据库报错 | 自动添加该字段 | ✅ shortcuts已处理 |
|
||||
| Issue更新 | 需保留`subject`/`description`,否则可能清空描述 | 先GET再提交,保留字段 | ⚠️ 需手动处理 |
|
||||
| Issue列表 | 分页参数可能不返回完整统计 | 客户端需分页处理 | ✅ 正常 |
|
||||
|
||||
### Release API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| Release查看 | 需要`version_id`不能用`tag_name` | 使用`release +list`获取ID | ⚠️ 注意参数 |
|
||||
| Release删除 | 需要`version_id` | 使用`release +delete -i <version_id>` | ✅ shortcuts已处理 |
|
||||
|
||||
### 分支API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| 分支操作 | 需要`/v1/`前缀 | 端点使用`/v1/:owner/:repo/branches` | ✅ shortcuts已处理 |
|
||||
| 分支删除 | `DELETE`分支API始终返回"分支不存在" | GitLink平台Bug,暂不支持 | ❌ API不可用 |
|
||||
|
||||
### 文件操作API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| 创建文件 | `content`字段必须base64编码 | 不编码会返回"文件已存在"错误 | ⚠️ 需手动处理 |
|
||||
| 更新文件 | 需要`sha`参数,通过`sub_entries`获取 | 先GET获取SHA再PUT | ⚠️ 复杂操作 |
|
||||
|
||||
### Pull Request API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| PR合并 | 需要`do`参数指定合并方式 | `pr +merge`已内置处理 | ✅ shortcuts已处理 |
|
||||
| PR列表 | `--state`参数只影响统计,列表可能包含所有状态 | 客户端需按`pull_request_status`过滤 | ⚠️ 需手动处理 |
|
||||
| PR创建 | 分支内容必须与目标分支不同 | 需要先有实际提交差异 | ⚠️ API限制 |
|
||||
|
||||
### Wiki API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| Wiki域名 | 使用Gateway域名而非主域名 | Wiki使用独立client处理 | ✅ shortcuts已处理 |
|
||||
| Wiki内容 | base64编码传输 | CLI自动编解码 | ✅ shortcuts已处理 |
|
||||
| Wiki删除 | API只清空内容,不删除侧边栏条目 | GitLink平台限制 | ⚠️ 部分功能 |
|
||||
|
||||
### Webhook API
|
||||
| API端点 | 问题 | 解决方案 | 状态 |
|
||||
|---------|------|----------|------|
|
||||
| Webhook创建 | 需要完整的URL和事件配置 | 按文档格式提交 | ✅ shortcuts已处理 |
|
||||
| Webhook测试 | 测试推送可能延迟 | 等待异步处理 | ✅ shortcuts已处理 |
|
||||
|
||||
## 推荐使用方式
|
||||
|
||||
### 优先级顺序
|
||||
1. **Shortcuts** - 最简单,自动处理特殊情况
|
||||
```bash
|
||||
gitlink-cli issue +create -t "Bug" -b "详细描述"
|
||||
gitlink-cli wiki +create --title "Home" --content "# 欢迎"
|
||||
```
|
||||
|
||||
2. **Raw API** - Shortcuts未覆盖时使用
|
||||
```bash
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"test","done_ratio":0}'
|
||||
```
|
||||
|
||||
3. **直接HTTP** - 仅用于调试或特殊需求
|
||||
```bash
|
||||
curl -X POST "https://www.gitlink.org.cn/api/:owner/:repo/issues.json?access_token=xxx"
|
||||
```
|
||||
|
||||
### 错误处理建议
|
||||
|
||||
**推荐错误处理流程**:
|
||||
1. 检查HTTP状态码
|
||||
2. 检查body中的status/code字段
|
||||
3. 验证实际修改是否生效(GET验证)
|
||||
4. 使用shortcuts避免直接处理复杂情况
|
||||
|
||||
**错误处理示例**:
|
||||
```python
|
||||
def check_gitlink_error(response):
|
||||
# 1. 检查HTTP状态码
|
||||
if response.status_code >= 400:
|
||||
return f"HTTP错误: {response.status_code}"
|
||||
|
||||
# 2. 检查body中的错误字段
|
||||
data = response.json()
|
||||
if 'status' in data and data['status'] != 200:
|
||||
return f"API错误: {data.get('message', '未知错误')}"
|
||||
if 'code' in data and data['code'] != 200:
|
||||
return f"Gateway错误: [{data['code']}] {data.get('msg', '未知错误')}"
|
||||
|
||||
# 3. 验证实际修改
|
||||
return None
|
||||
```
|
||||
|
||||
### 认证相关
|
||||
|
||||
**Token获取方式**:
|
||||
1. 用户名密码登录: `gitlink-cli auth login`
|
||||
2. 直接Token: `gitlink-cli auth login --token`
|
||||
3. 环境变量: `export GITLINK_TOKEN="your-token"`
|
||||
|
||||
**Token有效期**: 7天,过期需重新登录
|
||||
|
||||
**认证优先级**: 环境变量 > Keychain存储 > 交互式登录
|
||||
|
||||
### 请求限制
|
||||
|
||||
**速率限制**: GitLink API有基本的速率限制,建议:
|
||||
- 批量操作使用专门的batch命令
|
||||
- 避免短时间内大量请求
|
||||
- 使用`--dry-run`预览批量操作
|
||||
|
||||
**分页处理**: 大量数据建议:
|
||||
- 使用shortcuts的自动分页功能
|
||||
- 或者使用Raw API手动处理分页参数
|
||||
|
||||
## 开发建议
|
||||
|
||||
### 使用gitlink-cli的优势
|
||||
1. **自动处理特殊情况** - 如base64编码、双重错误码等
|
||||
2. **统一的错误处理** - 标准化的错误信息和建议
|
||||
3. **AI Agent友好** - 完整的Skills文档支持
|
||||
4. **跨平台支持** - macOS、Linux、Windows
|
||||
|
||||
### 调试技巧
|
||||
1. **使用`--debug`参数** 查看详细的请求响应
|
||||
```bash
|
||||
gitlink-cli --debug issue +list
|
||||
```
|
||||
|
||||
2. **使用`--format json`** 获取结构化输出
|
||||
```bash
|
||||
gitlink-cli --format json issue +list
|
||||
```
|
||||
|
||||
3. **使用`--dry-run`** 预览危险操作
|
||||
```bash
|
||||
gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 附件
|
||||
|
||||
## POST 上传文件
|
||||
|
|
@ -15206,6 +15359,46 @@ GET /api/wikiExport/wikiExport-wrapper
|
|||
|» data|object|false|none||none|
|
||||
|» message|string|false|none||none|
|
||||
|
||||
---
|
||||
|
||||
## Gateway Wiki API(CLI 实际调用)
|
||||
|
||||
gitlink-cli 的 wiki shortcut 实际调用的是 **gateway 域名**下的 `/wiki/open/*` 端点,而非上述 www 域名的 `/api/wiki/*` 端点。两者存在以下差异:
|
||||
|
||||
| 差异项 | www 域名(APIfox 文档) | gateway 域名(CLI 实际使用) |
|
||||
|--------|------------------------|---------------------------|
|
||||
| Base URL | `https://www.gitlink.org.cn/api` | `https://gateway.gitlink.org.cn/api` |
|
||||
| URL 前缀 | `/api/wiki/` | `/wiki/open/` |
|
||||
| JSON 后缀 | 需要 `.json` | 不需要 `.json` |
|
||||
| 响应格式 | 直接返回 data | `{"code": 200, "data": {...}, "msg": ""}` 包一层 |
|
||||
| 错误判断 | `status` 字段 ≠ 200 | `code` 字段 ≠ 200/201 |
|
||||
|
||||
### 实际调用路径对比
|
||||
|
||||
| 操作 | www 文档路径 | gateway 实际路径 |
|
||||
|------|-------------|-----------------|
|
||||
| 创建 | `POST /api/wiki/createWiki.json` | `POST /wiki/open/createWiki` |
|
||||
| 删除 | `DELETE /api/wiki/deleteWiki.json` | `DELETE /wiki/open/deleteWiki` |
|
||||
| 查看 | `GET /api/wiki/getWiki.json` | `GET /wiki/open/getWiki` |
|
||||
| 更新 | `PUT /api/wiki/updateWiki.json` | `PUT /wiki/open/updateWiki` |
|
||||
| 列表 | `GET /api/wiki/wikiPages.json` | `GET /wiki/open/wikiPages` |
|
||||
|
||||
### 响应格式差异示例
|
||||
|
||||
**www 域名响应**(标准格式):
|
||||
```json
|
||||
{"data": {"title": "test", "content": "..."}}
|
||||
```
|
||||
|
||||
**gateway 域名响应**(包一层):
|
||||
```json
|
||||
{"code": 200, "data": {"title": "test", "content": "..."}, "msg": "success"}
|
||||
```
|
||||
|
||||
CLI 通过 `unwrapGatewayResponse()` 函数自动解包 gateway 格式,对用户透明。
|
||||
|
||||
> **注意**:project_id 仍需通过 www 域名的 `/api/{owner}/{repo}/detail.json` 获取,两个域名不可互换。
|
||||
|
||||
# 流水线
|
||||
|
||||
## GET 流水线列表
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,441 @@
|
|||
# GitLink CLI Windows 安装脚本
|
||||
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
|
||||
# 支持: .\install.ps1 -Version 0.1.0 -InstallDir "C:\GitLink"
|
||||
|
||||
param(
|
||||
[string]$Version = "latest",
|
||||
[string]$InstallDir = "$HOME\.gitlink-cli\bin",
|
||||
[switch]$Help = $false,
|
||||
[switch]$Uninstall = $false,
|
||||
[switch]$ListVersions = $false,
|
||||
[switch]$Debug = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$REPO_OWNER = "Gitlink"
|
||||
$REPO_NAME = "gitlink-cli"
|
||||
$BINARY_NAME = "gitlink-cli"
|
||||
$API_BASE = "https://www.gitlink.org.cn"
|
||||
$SKILLS_DIR = "$HOME\.gitlink\skills"
|
||||
|
||||
# 颜色输出
|
||||
function Info {
|
||||
param([string]$msg)
|
||||
Write-Host "[INFO] $msg" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Warn {
|
||||
param([string]$msg)
|
||||
Write-Host "[WARN] $msg" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function ErrorMsg {
|
||||
param([string]$msg)
|
||||
Write-Host "[ERROR] $msg" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Step {
|
||||
param([string]$msg)
|
||||
Write-Host "[STEP] $msg" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function DebugMsg {
|
||||
param([string]$msg)
|
||||
if ($Debug) {
|
||||
Write-Host "[DEBUG] $msg" -ForegroundColor Blue
|
||||
}
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
function Show-Help {
|
||||
Write-Host @"
|
||||
GitLink CLI Windows 安装脚本
|
||||
|
||||
用法:
|
||||
.\install.ps1 [选项]
|
||||
|
||||
选项:
|
||||
-Version <version> 指定版本 (默认: latest)
|
||||
-InstallDir <path> 安装目录 (默认: $HOME\.gitlink-cli\bin)
|
||||
-Uninstall 卸载
|
||||
-ListVersions 列出可用版本
|
||||
-Debug 显示调试信息
|
||||
-Help 显示此帮助
|
||||
|
||||
示例:
|
||||
# 安装最新版本
|
||||
.\install.ps1
|
||||
|
||||
# 安装指定版本
|
||||
.\install.ps1 -Version "0.1.0"
|
||||
|
||||
# 安装到指定目录
|
||||
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
|
||||
|
||||
# 列出可用版本
|
||||
.\install.ps1 -ListVersions
|
||||
|
||||
# 卸载
|
||||
.\install.ps1 -Uninstall
|
||||
|
||||
# 在线安装
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
|
||||
"@
|
||||
}
|
||||
|
||||
# 检测架构
|
||||
function Get-PlatformInfo {
|
||||
$arch = switch ([System.Runtime.InteropServices::RuntimeInformation]::OSArchitecture) {
|
||||
"X64" { "amd64" }
|
||||
"Arm64" { "arm64" }
|
||||
"X86" { "386"; Warn "32位系统支持有限" }
|
||||
default { throw "不支持的架构: $arch" }
|
||||
}
|
||||
return @{ Platform = "windows"; Arch = $arch }
|
||||
}
|
||||
|
||||
# 检查环境
|
||||
function Test-Environment {
|
||||
Step "检查安装环境..."
|
||||
|
||||
# 检查磁盘空间(至少50MB)
|
||||
$drive = (Get-Item $InstallDir).PSDrive.Name
|
||||
$driveInfo = Get-PSDrive $drive
|
||||
$freeSpaceMB = [math]::Round($driveInfo.Free / 1MB, 2)
|
||||
|
||||
if ($freeSpaceMB -lt 50) {
|
||||
ErrorMsg "磁盘空间不足(需要至少50MB,可用: ${freeSpaceMB}MB)"
|
||||
exit 1
|
||||
}
|
||||
DebugMsg "磁盘空间检查通过: ${freeSpaceMB}MB"
|
||||
|
||||
# 检查网络连接
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $API_BASE -UseBasicParsing -TimeoutSec 5 -Method Head
|
||||
DebugMsg "网络连接检查通过"
|
||||
}
|
||||
catch {
|
||||
ErrorMsg "无法连接到 GitLink 服务器: $API_BASE"
|
||||
ErrorMsg "请检查网络连接"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Info "环境检查通过"
|
||||
}
|
||||
|
||||
# 下载文件(带重试)
|
||||
function Download-File {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Output,
|
||||
[int]$MaxAttempts = 3
|
||||
)
|
||||
|
||||
$attempt = 1
|
||||
while ($attempt -le $MaxAttempts) {
|
||||
try {
|
||||
Step "下载 (尝试 $attempt/$MaxAttempts): $(Split-Path $Url -Leaf)"
|
||||
DebugMsg "URL: $Url"
|
||||
|
||||
# 使用WebClient,支持进度显示
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
|
||||
# 注册下载进度事件
|
||||
Register-ObjectEvent -InputObject $webClient -EventName DownloadProgressChanged -SourceIdentifier WebClient.DownloadProgressChanged -Action {
|
||||
global:Progress = $EventArgs.ProgressPercentage
|
||||
Write-Progress -Activity "下载中" -Status "$($EventArgs.ProgressPercentage)% 完成" -PercentComplete $EventArgs.ProgressPercentage
|
||||
} | Out-Null
|
||||
|
||||
# 注册下载完成事件
|
||||
Register-ObjectEvent -InputObject $webClient -EventName DownloadFileCompleted -SourceIdentifier WebClient.DownloadFileCompleted -Action {
|
||||
global:DownloadComplete = $true
|
||||
} | Out-Null
|
||||
|
||||
# 开始下载
|
||||
$webClient.DownloadFileAsync($Url, $Output)
|
||||
|
||||
# 等待下载完成
|
||||
while (-not $global:DownloadComplete) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
|
||||
Write-Progress -Activity "下载中" -Completed
|
||||
|
||||
# 清理事件
|
||||
Unregister-Event -SourceIdentifier WebClient.DownloadProgressChanged -ErrorAction SilentlyContinue
|
||||
Unregister-Event -SourceIdentifier WebClient.DownloadFileCompleted -ErrorAction SilentlyContinue
|
||||
$webClient.Dispose()
|
||||
|
||||
if (Test-Path $Output) {
|
||||
$size = [math]::Round((Get-Item $Output).Length / 1MB, 2)
|
||||
Info "下载成功: $(Split-Path $Output -Leaf) (${size}MB)"
|
||||
return $true
|
||||
}
|
||||
else {
|
||||
Warn "下载文件为空"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Warn "下载失败: $_"
|
||||
}
|
||||
|
||||
if ($attempt -lt $MaxAttempts) {
|
||||
$waitTime = $attempt * 2
|
||||
Warn "等待 ${waitTime}s 后重试..."
|
||||
Start-Sleep -Seconds $waitTime
|
||||
}
|
||||
|
||||
$attempt++
|
||||
}
|
||||
|
||||
ErrorMsg "下载失败,已尝试 $MaxAttempts 次"
|
||||
ErrorMsg "URL: $Url"
|
||||
return $false
|
||||
}
|
||||
|
||||
# 获取最新版本
|
||||
function Get-LatestVersion {
|
||||
Step "获取最新版本..."
|
||||
|
||||
try {
|
||||
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
|
||||
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
|
||||
|
||||
if ($releases -and $releases.Count -gt 0) {
|
||||
return $releases[0].tag_name
|
||||
}
|
||||
else {
|
||||
Warn "无法获取版本信息,使用默认版本"
|
||||
return "v0.1.0"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Warn "获取版本失败: $_"
|
||||
return "v0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
# 列出可用版本
|
||||
function Show-AvailableVersions {
|
||||
Step "查询可用版本..."
|
||||
|
||||
try {
|
||||
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
|
||||
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
|
||||
|
||||
if ($releases -and $releases.Count -gt 0) {
|
||||
Info "可用版本:"
|
||||
foreach ($release in $releases | Select-Object -First 10) {
|
||||
Write-Host " - $($release.tag_name)" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
else {
|
||||
Warn "无法获取版本列表"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
ErrorMsg "获取版本列表失败: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# 解压zip文件
|
||||
function Expand-ZipFile {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
[string]$DestDir
|
||||
)
|
||||
|
||||
Step "解压..."
|
||||
DebugMsg "解压 $ZipPath 到 $DestDir"
|
||||
|
||||
try {
|
||||
Expand-Archive -Force -Path $ZipPath -DestinationPath $DestDir
|
||||
Info "解压完成"
|
||||
}
|
||||
catch {
|
||||
ErrorMsg "解压失败: $_"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# 添加到PATH
|
||||
function Add-ToPath {
|
||||
param([string]$Dir)
|
||||
|
||||
$path = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if ($path -notlike "*$Dir*") {
|
||||
Step "添加到PATH: $Dir"
|
||||
[Environment]::SetEnvironmentVariable("Path", "$path;$Dir", "User")
|
||||
Warn "请重启终端使PATH生效"
|
||||
}
|
||||
else {
|
||||
DebugMsg "已在PATH中: $Dir"
|
||||
}
|
||||
}
|
||||
|
||||
# 验证安装
|
||||
function Test-Installation {
|
||||
Step "验证安装..."
|
||||
|
||||
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
|
||||
|
||||
if (Test-Path $binaryPath) {
|
||||
try {
|
||||
$versionOutput = & $binaryPath version 2>$null
|
||||
Info "安装成功! $versionOutput"
|
||||
|
||||
# 添加到PATH
|
||||
Add-ToPath $InstallDir
|
||||
|
||||
Write-Host ""
|
||||
Info "快速开始:"
|
||||
Info " gitlink-cli auth login # 登录账号"
|
||||
Info " gitlink-cli --help # 查看所有命令"
|
||||
Info " gitlink-cli version # 查看版本信息"
|
||||
Write-Host ""
|
||||
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
ErrorMsg "执行二进制文件失败: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
else {
|
||||
ErrorMsg "安装验证失败: $binaryPath 不存在"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# 主安装流程
|
||||
function Install-CLI {
|
||||
Write-Host ""
|
||||
Write-Host " ╔══════════════════════════════════════╗"
|
||||
Write-Host " ║ GitLink CLI 一键安装 (Windows) ║"
|
||||
Write-Host " ╚══════════════════════════════════════╝"
|
||||
Write-Host ""
|
||||
|
||||
# 检测平台
|
||||
$platform = Get-PlatformInfo
|
||||
Info "检测到平台: $($platform.Platform)-$($platform.Arch)"
|
||||
|
||||
# 创建安装目录
|
||||
if (!(Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
Info "创建安装目录: $InstallDir"
|
||||
}
|
||||
|
||||
# 检查环境
|
||||
Test-Environment
|
||||
|
||||
# 获取版本
|
||||
if ($Version -eq "latest") {
|
||||
$Version = Get-LatestVersion
|
||||
Info "最新版本: $Version"
|
||||
}
|
||||
else {
|
||||
Info "指定版本: $Version"
|
||||
}
|
||||
|
||||
# 下载
|
||||
$zipName = "$BINARY_NAME_${Version}_windows_$($platform.Arch).zip"
|
||||
$zipUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$zipName"
|
||||
$zipPath = Join-Path $env:TEMP $zipName
|
||||
|
||||
if (-not (Download-File -Url $zipUrl -Output $zipPath)) {
|
||||
ErrorMsg "请确认以下版本已发布: $Version"
|
||||
ErrorMsg "发布页: $API_BASE/$REPO_OWNER/$REPO_NAME/releases"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 解压
|
||||
Expand-ZipFile -ZipPath $zipPath -DestDir $InstallDir
|
||||
Remove-Item $zipPath
|
||||
|
||||
# 安装skills
|
||||
Step "安装 skills..."
|
||||
if (!(Test-Path $SKILLS_DIR)) {
|
||||
New-Item -ItemType Directory -Path $SKILLS_DIR -Force | Out-Null
|
||||
}
|
||||
|
||||
$skillsZipName = "$BINARY_NAME_${Version}_skills.zip"
|
||||
$skillsUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$skillsZipName"
|
||||
$skillsZipPath = Join-Path $env:TEMP $skillsZipName
|
||||
|
||||
if (Download-File -Url $skillsUrl -Output $skillsZipPath) {
|
||||
try {
|
||||
Expand-Archive -Force -Path $skillsZipPath -DestinationPath $SKILLS_DIR
|
||||
Info "Skills 安装到: $SKILLS_DIR"
|
||||
}
|
||||
catch {
|
||||
Warn "Skills 解压失败(可稍后手动安装)"
|
||||
}
|
||||
Remove-Item $skillsZipPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
Warn "Skills 包不可用(可稍后手动安装)"
|
||||
}
|
||||
|
||||
# 验证
|
||||
if (-not (Test-Installation)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 卸载
|
||||
function Uninstall-CLI {
|
||||
Step "卸载 $BINARY_NAME..."
|
||||
|
||||
# 删除二进制
|
||||
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
|
||||
if (Test-Path $binaryPath) {
|
||||
Remove-Item $binaryPath -Force
|
||||
Info "已删除: $binaryPath"
|
||||
}
|
||||
|
||||
# 删除skills
|
||||
if (Test-Path $SKILLS_DIR) {
|
||||
Remove-Item $SKILLS_DIR -Recurse -Force
|
||||
Info "已删除: $SKILLS_DIR"
|
||||
}
|
||||
|
||||
# 删除配置(可选)
|
||||
$response = Read-Host "是否删除配置文件? [y/N]"
|
||||
if ($response -eq 'y' -or $response -eq 'Y') {
|
||||
$configDir = Join-Path $env:APPDATA "gitlink-cli"
|
||||
if (Test-Path $configDir) {
|
||||
Remove-Item $configDir -Recurse -Force
|
||||
Info "已删除配置文件"
|
||||
}
|
||||
}
|
||||
|
||||
Info "卸载完成"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
function Main {
|
||||
if ($Help) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($ListVersions) {
|
||||
Show-AvailableVersions
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
Uninstall-CLI
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
Install-CLI
|
||||
}
|
||||
catch {
|
||||
ErrorMsg "安装失败: $_"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Main
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
#!/bin/bash
|
||||
# GitLink CLI 一键安装脚本(改进版)
|
||||
# 自动检测平台,下载预编译二进制,安装 skills
|
||||
# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
# 支持: VERSION=0.1.0 INSTALL_DIR=$HOME/.local/bin bash install.sh
|
||||
|
||||
set -e
|
||||
|
||||
REPO_OWNER="Gitlink"
|
||||
REPO_NAME="gitlink-cli"
|
||||
BINARY_NAME="gitlink-cli"
|
||||
API_BASE="https://www.gitlink.org.cn"
|
||||
INSTALL_DIR="${INSTALL_DIR:-auto}"
|
||||
SKILLS_DIR="${HOME}/.gitlink/skills"
|
||||
VERSION="${VERSION:-latest}"
|
||||
MAX_ATTEMPTS=3
|
||||
DEBUG="${DEBUG:-false}"
|
||||
|
||||
# ---------- 颜色输出 ----------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
debug() { [[ "${DEBUG}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $*"; }
|
||||
|
||||
# ---------- 环境检测 ----------
|
||||
check_environment() {
|
||||
step "检查安装环境..."
|
||||
|
||||
# 检查必需命令
|
||||
local required_commands=("curl" "tar")
|
||||
for cmd in "${required_commands[@]}"; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
error "缺少必需命令: $cmd"
|
||||
error "请安装后再试:"
|
||||
error " Ubuntu/Debian: sudo apt-get install $cmd"
|
||||
error " CentOS/RHEL: sudo yum install $cmd"
|
||||
error " macOS: brew install $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
debug "必需命令检查通过"
|
||||
|
||||
# 检查磁盘空间(至少50MB)
|
||||
local available_space
|
||||
available_space=$(df -m "$HOME" | tail -1 | awk '{print $4}')
|
||||
if [ "$available_space" -lt 50 ]; then
|
||||
error "磁盘空间不足(需要至少50MB,可用: ${available_space}MB)"
|
||||
exit 1
|
||||
fi
|
||||
debug "磁盘空间检查通过: ${available_space}MB"
|
||||
|
||||
# 检查网络连接
|
||||
if ! curl -sSL --connect-timeout 5 "${API_BASE}" >/dev/null 2>&1; then
|
||||
error "无法连接到 GitLink 服务器: ${API_BASE}"
|
||||
error "请检查网络连接"
|
||||
exit 1
|
||||
fi
|
||||
debug "网络连接检查通过"
|
||||
|
||||
info "环境检查通过"
|
||||
}
|
||||
|
||||
# ---------- 智能权限处理 ----------
|
||||
select_install_dir() {
|
||||
local dirs=("$HOME/.local/bin" "$HOME/bin" "/usr/local/bin")
|
||||
local selected_dir=""
|
||||
|
||||
if [ "${INSTALL_DIR}" != "auto" ]; then
|
||||
echo "${INSTALL_DIR}"
|
||||
return
|
||||
fi
|
||||
|
||||
step "选择安装目录..."
|
||||
|
||||
# 优先级:用户目录 > 系统目录
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ -w "$dir" ] 2>/dev/null || mkdir -p "$dir" 2>/dev/null; then
|
||||
selected_dir="$dir"
|
||||
info "选择用户目录: $selected_dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 如果用户目录都不可写,尝试系统目录
|
||||
if [ -z "$selected_dir" ]; then
|
||||
warn "无法写入用户目录,将使用系统目录(需要sudo权限)"
|
||||
selected_dir="/usr/local/bin"
|
||||
fi
|
||||
|
||||
echo "$selected_dir"
|
||||
}
|
||||
|
||||
# ---------- 下载重试机制 ----------
|
||||
download_with_retry() {
|
||||
local url="$1"
|
||||
local output="$2"
|
||||
local attempt=1
|
||||
|
||||
while [ $attempt -le $MAX_ATTEMPTS ]; do
|
||||
step "下载 (尝试 $attempt/$MAX_ATTEMPTS): $(basename "$url")"
|
||||
|
||||
if curl -sSL --connect-timeout 10 --max-time 120 --progress-bar "$url" -o "$output" 2>&1; then
|
||||
if [ -s "$output" ]; then
|
||||
info "下载成功: $(basename "$output") ($(du -h "$output" | cut -f1))"
|
||||
return 0
|
||||
else
|
||||
warn "下载文件为空"
|
||||
fi
|
||||
else
|
||||
warn "下载失败"
|
||||
fi
|
||||
|
||||
if [ $attempt -lt $MAX_ATTEMPTS ]; then
|
||||
local wait_time=$((attempt * 2))
|
||||
warn "等待 ${wait_time}s 后重试..."
|
||||
sleep $wait_time
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
error "下载失败,已尝试 $MAX_ATTEMPTS 次"
|
||||
error "URL: $url"
|
||||
error "请检查网络连接或手动下载"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 平台检测 ----------
|
||||
detect_platform() {
|
||||
local os arch
|
||||
case "$(uname -s)" in
|
||||
Linux) os="linux" ;;
|
||||
Darwin) os="darwin" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||
*) error "不支持的操作系统: $(uname -s)"; exit 1 ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="amd64" ;;
|
||||
aarch64|arm64) arch="arm64" ;;
|
||||
*) error "不支持的架构: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
echo "${os}/${arch}"
|
||||
}
|
||||
|
||||
# ---------- 获取最新版本 ----------
|
||||
fetch_latest_version() {
|
||||
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
|
||||
|
||||
info "获取最新版本..."
|
||||
|
||||
local releases
|
||||
releases=$(curl -sSL --connect-timeout 10 --max-time 30 "${releases_url}" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$releases" ]; then
|
||||
error "无法获取发布列表: ${releases_url}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 尝试解析第一个 release 的 tag_name
|
||||
local tag
|
||||
tag=$(echo "$releases" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
# 备选:尝试直接匹配数组第一个元素的 tag_name
|
||||
tag=$(echo "$releases" | grep -oP '"tag_name"\s*:\s*"\K[^"]+' | head -1)
|
||||
fi
|
||||
|
||||
echo "${tag:-v0.1.0}"
|
||||
}
|
||||
|
||||
# ---------- 显示已安装版本 ----------
|
||||
show_installed_version() {
|
||||
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
|
||||
"${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
|
||||
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
|
||||
"${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
|
||||
else
|
||||
echo "未安装"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 列出可用版本 ----------
|
||||
list_versions() {
|
||||
step "查询可用版本..."
|
||||
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
|
||||
curl -sSL "$releases_url" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -10
|
||||
}
|
||||
|
||||
# ---------- 回滚功能 ----------
|
||||
rollback_version() {
|
||||
local target_version="$1"
|
||||
|
||||
if [ -z "$target_version" ]; then
|
||||
error "请指定要回滚到的版本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step "回滚到版本: ${target_version}"
|
||||
|
||||
# 重新安装指定版本
|
||||
VERSION="$target_version"
|
||||
main
|
||||
}
|
||||
|
||||
# ---------- 卸载功能 ----------
|
||||
uninstall() {
|
||||
step "卸载 ${BINARY_NAME}..."
|
||||
|
||||
# 删除二进制
|
||||
if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
|
||||
if [ -w "${INSTALL_DIR}" ]; then
|
||||
rm -f "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
info "已删除: ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
else
|
||||
sudo rm -f "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
info "已删除: ${INSTALL_DIR}/${BINARY_NAME} (使用sudo)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 删除skills
|
||||
if [ -d "${SKILLS_DIR}" ]; then
|
||||
rm -rf "${SKILLS_DIR}"
|
||||
info "已删除: ${SKILLS_DIR}"
|
||||
fi
|
||||
|
||||
# 删除配置(可选)
|
||||
echo -n "是否删除配置文件? [y/N] "
|
||||
read -r response
|
||||
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||
rm -rf "$HOME/.config/gitlink-cli"
|
||||
rm -rf "$HOME/.gitlink"
|
||||
info "已删除配置文件"
|
||||
fi
|
||||
|
||||
info "卸载完成"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------- 下载二进制 ----------
|
||||
download_binary() {
|
||||
local platform="$1"
|
||||
local version="$2"
|
||||
local os="${platform%/*}"
|
||||
local arch="${platform#*/}"
|
||||
local archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
|
||||
local download_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archive_name}"
|
||||
|
||||
info "下载: ${archive_name}"
|
||||
debug "URL: ${download_url}"
|
||||
|
||||
local tmpdir
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap "rm -rf ${tmpdir}" EXIT
|
||||
|
||||
if ! download_with_retry "${download_url}" "${tmpdir}/${archive_name}"; then
|
||||
error "请确认以下版本已发布: ${version}"
|
||||
error "发布页: ${API_BASE}/${REPO_OWNER}/${REPO_NAME}/releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step "解压..."
|
||||
tar -xzf "${tmpdir}/${archive_name}" -C "${tmpdir}"
|
||||
|
||||
local binary_path="${tmpdir}/${BINARY_NAME}"
|
||||
if [ ! -f "${binary_path}" ]; then
|
||||
# 可能在子目录中
|
||||
binary_path=$(find "${tmpdir}" -name "${BINARY_NAME}" -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ ! -f "${binary_path}" ]; then
|
||||
error "解压后未找到二进制文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "${binary_path}"
|
||||
|
||||
# ---------- 安装 ----------
|
||||
step "安装到 ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
if [ ! -w "${INSTALL_DIR}" ]; then
|
||||
warn "需要管理员权限写入 ${INSTALL_DIR}"
|
||||
sudo mkdir -p "${INSTALL_DIR}"
|
||||
sudo mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
else
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
fi
|
||||
|
||||
info "二进制: ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
|
||||
# ---------- 安装 skills ----------
|
||||
install_skills() {
|
||||
local version="$1"
|
||||
step "安装 skills..."
|
||||
|
||||
mkdir -p "${SKILLS_DIR}"
|
||||
|
||||
local skills_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${BINARY_NAME}_${version}_skills.tar.gz"
|
||||
local tmpdir
|
||||
tmpdir="$(mktemp -d)"
|
||||
|
||||
if download_with_retry "${skills_url}" "${tmpdir}/skills.tar.gz" 2>/dev/null; then
|
||||
tar -xzf "${tmpdir}/skills.tar.gz" -C "${SKILLS_DIR}" 2>/dev/null || true
|
||||
info "Skills 安装到: ${SKILLS_DIR}"
|
||||
else
|
||||
warn "Skills 包不可用(可稍后通过 gitlink-cli-install-skills 安装)"
|
||||
fi
|
||||
|
||||
rm -rf "${tmpdir}"
|
||||
}
|
||||
|
||||
# ---------- 验证安装 ----------
|
||||
verify() {
|
||||
step "验证安装..."
|
||||
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
|
||||
info "安装成功! $("${BINARY_NAME}" version 2>/dev/null || echo "${BINARY_NAME}")"
|
||||
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
|
||||
info "安装成功! $("${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "${INSTALL_DIR}/${BINARY_NAME}")"
|
||||
warn "请将 ${INSTALL_DIR} 添加到 PATH: export PATH=${INSTALL_DIR}:\$PATH"
|
||||
else
|
||||
error "安装验证失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 显示帮助 ----------
|
||||
show_help() {
|
||||
cat << EOF
|
||||
GitLink CLI 安装脚本
|
||||
|
||||
用法:
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash [选项]
|
||||
|
||||
选项:
|
||||
VERSION=0.1.0 指定版本
|
||||
INSTALL_DIR=/path 指定安装目录
|
||||
DEBUG=true 显示调试信息
|
||||
|
||||
环境变量:
|
||||
INSTALL_DIR 安装目录(默认: auto,自动选择)
|
||||
VERSION 版本(默认: latest)
|
||||
|
||||
命令:
|
||||
list 列出可用版本
|
||||
uninstall 卸载
|
||||
rollback VERSION 回滚到指定版本
|
||||
|
||||
示例:
|
||||
# 安装最新版本
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 安装指定版本
|
||||
VERSION=0.1.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 安装到用户目录
|
||||
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
|
||||
# 列出可用版本
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
|
||||
|
||||
# 卸载
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- main ----------
|
||||
main() {
|
||||
echo ""
|
||||
echo " ╔══════════════════════════════════════╗"
|
||||
echo " ║ GitLink CLI 一键安装 ║"
|
||||
echo " ╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 处理命令行参数
|
||||
case "${1:-}" in
|
||||
list)
|
||||
list_versions
|
||||
exit 0
|
||||
;;
|
||||
uninstall)
|
||||
INSTALL_DIR="${INSTALL_DIR:-$(select_install_dir)}"
|
||||
uninstall
|
||||
;;
|
||||
rollback)
|
||||
rollback_version "$2"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# 环境检测
|
||||
check_environment
|
||||
|
||||
# 选择安装目录
|
||||
INSTALL_DIR=$(select_install_dir)
|
||||
export INSTALL_DIR
|
||||
|
||||
local platform
|
||||
platform=$(detect_platform)
|
||||
info "检测到平台: ${platform}"
|
||||
|
||||
local version
|
||||
if [ "${VERSION}" = "latest" ]; then
|
||||
version=$(fetch_latest_version)
|
||||
info "最新版本: ${version}"
|
||||
else
|
||||
version="${VERSION}"
|
||||
info "指定版本: ${version}"
|
||||
fi
|
||||
|
||||
download_binary "${platform}" "${version}"
|
||||
install_skills "${version}"
|
||||
verify
|
||||
|
||||
echo ""
|
||||
info "快速开始:"
|
||||
info " gitlink-cli auth login # 登录账号"
|
||||
info " gitlink-cli --help # 查看所有命令"
|
||||
info " gitlink-cli version # 查看版本信息"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -71,7 +71,7 @@ func Login(username, password string) (*LoginResult, error) {
|
|||
// Collect auth cookies from response (GitLink uses autologin_trustie for session persistence)
|
||||
var authCookies []string
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
|
||||
if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" {
|
||||
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ func Login(username, password string) (*LoginResult, error) {
|
|||
if len(authCookies) == 0 {
|
||||
if u, err := url.Parse(loginURL); err == nil {
|
||||
for _, cookie := range jar.Cookies(u) {
|
||||
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
|
||||
if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" {
|
||||
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,19 +11,23 @@ import (
|
|||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
BaseURL string
|
||||
Debug bool
|
||||
HTTP *http.Client
|
||||
BaseURL string
|
||||
Debug bool
|
||||
SkipJSONSuffix bool
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Kind clierrors.ErrorKind
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
|
|
@ -44,14 +48,14 @@ func New() (*Client, error) {
|
|||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
// 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 !strings.HasSuffix(basePath, ".json") {
|
||||
if c.shouldAppendJSONSuffix(path) {
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
path = basePath + ".json" + queryStr
|
||||
} else {
|
||||
path += ".json"
|
||||
}
|
||||
} else if !strings.HasSuffix(path, ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil && len(query) > 0 {
|
||||
|
|
@ -98,10 +102,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
|
||||
// Check HTTP-level errors
|
||||
if resp.StatusCode >= 400 {
|
||||
info := lookupStatusInfo(resp.StatusCode)
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
Kind: info.kind,
|
||||
Suggestion: info.suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,11 +130,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||
msg, _ := raw["message"].(string)
|
||||
suggestion := suggestFix(int(statusCode))
|
||||
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
|
||||
info := lookupStatusInfo(int(statusCode))
|
||||
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
|
||||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Kind: info.kind,
|
||||
Suggestion: info.suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,17 +183,64 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
|||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
|
||||
func suggestFix(code int) string {
|
||||
switch code {
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
default:
|
||||
return ""
|
||||
type statusInfo struct {
|
||||
kind clierrors.ErrorKind
|
||||
message string
|
||||
suggestion string
|
||||
}
|
||||
|
||||
var statusMessages = map[int]statusInfo{
|
||||
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
|
||||
"运行 gitlink-cli auth login 重新登录,或检查 GITLINK_TOKEN 环境变量"},
|
||||
-1: {clierrors.KindInput, "参数校验失败",
|
||||
"检查必填参数是否缺失、参数格式是否正确,运行 gitlink-cli <命令> --help 查看用法"},
|
||||
0: {clierrors.KindUnknown, "操作失败", ""},
|
||||
// Standard HTTP codes
|
||||
401: {clierrors.KindAuth, "认证失败",
|
||||
"运行 gitlink-cli auth login 登录,或检查 GITLINK_TOKEN 环境变量"},
|
||||
403: {clierrors.KindForbidden, "权限不足",
|
||||
"请确认账号有此仓库的访问权限,或联系项目管理员"},
|
||||
404: {clierrors.KindNotFound, "资源不存在",
|
||||
"检查 owner/repo/id 是否正确,资源可能已被删除"},
|
||||
422: {clierrors.KindInput, "参数校验失败",
|
||||
"检查请求参数格式,运行 gitlink-cli <命令> --help 查看用法"},
|
||||
429: {clierrors.KindServer, "请求过于频繁",
|
||||
"稍等片刻后重试"},
|
||||
500: {clierrors.KindServer, "服务器内部错误",
|
||||
"稍等后重试,如持续出现请联系平台管理员"},
|
||||
502: {clierrors.KindServer, "网关错误",
|
||||
"服务器暂时不可用,稍等后重试"},
|
||||
503: {clierrors.KindServer, "服务暂时不可用",
|
||||
"服务器正在维护,稍等后重试"},
|
||||
}
|
||||
|
||||
func lookupStatusInfo(code int) statusInfo {
|
||||
if info, ok := statusMessages[code]; ok {
|
||||
return info
|
||||
}
|
||||
return statusInfo{
|
||||
kind: clierrors.KindUnknown,
|
||||
message: fmt.Sprintf("API 返回错误码 %d", code),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path.
|
||||
// Returns false (skip append) when:
|
||||
// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway)
|
||||
// - path already ends with .json
|
||||
// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...)
|
||||
func (c *Client) shouldAppendJSONSuffix(path string) bool {
|
||||
if c.SkipJSONSuffix {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "raw" && i >= 2 && i+2 < len(parts) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,27 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://www.gitlink.org.cn/api"
|
||||
DefaultFormat = "table"
|
||||
DefaultBaseURL = "https://www.gitlink.org.cn/api"
|
||||
DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api"
|
||||
DefaultFormat = "table"
|
||||
|
||||
// EnvGatewayBaseURL overrides GatewayBaseURL when set.
|
||||
EnvGatewayBaseURL = "GITLINK_GATEWAY_URL"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
GatewayBaseURL string `yaml:"gateway_base_url,omitempty"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
BaseURL: DefaultBaseURL,
|
||||
Format: DefaultFormat,
|
||||
BaseURL: DefaultBaseURL,
|
||||
GatewayBaseURL: DefaultGatewayBaseURL,
|
||||
Format: DefaultFormat,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +59,12 @@ func Load() (*Config, error) {
|
|||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = DefaultBaseURL
|
||||
}
|
||||
if cfg.GatewayBaseURL == "" {
|
||||
cfg.GatewayBaseURL = DefaultGatewayBaseURL
|
||||
}
|
||||
if v := os.Getenv(EnvGatewayBaseURL); v != "" {
|
||||
cfg.GatewayBaseURL = v
|
||||
}
|
||||
if cfg.Format == "" {
|
||||
cfg.Format = DefaultFormat
|
||||
}
|
||||
|
|
@ -79,6 +91,8 @@ func Get(key string) (string, error) {
|
|||
switch key {
|
||||
case "base_url":
|
||||
return cfg.BaseURL, nil
|
||||
case "gateway_base_url":
|
||||
return cfg.GatewayBaseURL, nil
|
||||
case "default_format":
|
||||
return cfg.Format, nil
|
||||
case "editor":
|
||||
|
|
@ -98,6 +112,8 @@ func Set(key, value string) error {
|
|||
switch key {
|
||||
case "base_url":
|
||||
cfg.BaseURL = value
|
||||
case "gateway_base_url":
|
||||
cfg.GatewayBaseURL = value
|
||||
case "default_format":
|
||||
cfg.Format = value
|
||||
case "editor":
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
|
|||
owner, repo, err := fromGitRemote()
|
||||
if err != nil {
|
||||
if flagOwner == "" || flagRepo == "" {
|
||||
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err)
|
||||
return "", "", fmt.Errorf("无法自动检测 owner/repo: %w\n 请使用 --owner 和 --repo 参数显式指定,或切换到 git 仓库目录下执行", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,11 +59,33 @@ func parseRemoteURL(remote string) (string, string, error) {
|
|||
}
|
||||
|
||||
func parsePathSegments(path string) (string, string, error) {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
// 处理URL路径格式:可能包含域名前缀
|
||||
// 例如://www.gitlink.org.cn/zzx-coder/gitlink-cli 或 /zzx-coder/gitlink-cli
|
||||
|
||||
// 先去掉域名部分(如果存在)
|
||||
if strings.HasPrefix(path, "//www.gitlink.org.cn/") {
|
||||
path = strings.TrimPrefix(path, "//www.gitlink.org.cn/")
|
||||
} else if strings.HasPrefix(path, "//") {
|
||||
// 处理其他可能的域名格式:找到第二个斜杠后的内容
|
||||
if idx := strings.Index(path[2:], "/"); idx != -1 {
|
||||
path = path[2+idx+1:]
|
||||
} else {
|
||||
path = path[2:]
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/") {
|
||||
// 去掉单个前导斜杠
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
}
|
||||
|
||||
// 去掉.git后缀
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
|
||||
// 现在应该得到 "zzx-coder/gitlink-cli" 格式
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path)
|
||||
}
|
||||
|
||||
// 第一个部分是owner,第二个是repo(可能还有更多部分但忽略)
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrorKind categorizes errors by user-actionability.
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
KindAuth ErrorKind = "auth" // Login/token issues — user can re-login
|
||||
KindInput ErrorKind = "input" // Parameter issues — user can fix arguments
|
||||
KindConfig ErrorKind = "config" // Config file issues — user can edit config
|
||||
KindNetwork ErrorKind = "network" // Network issues — user can check/retry
|
||||
KindGit ErrorKind = "git" // Git repo issues — user needs correct directory
|
||||
KindServer ErrorKind = "server" // Server-side error — user should wait or contact admin
|
||||
KindNotFound ErrorKind = "not_found" // Resource not found — user can check ID
|
||||
KindForbidden ErrorKind = "forbidden" // Permission denied — user can request access
|
||||
KindUnknown ErrorKind = "unknown" // Unclassified error
|
||||
)
|
||||
|
||||
// CLIError is the unified CLI error type with multi-layered information.
|
||||
type CLIError struct {
|
||||
Kind ErrorKind // Error category for programmatic handling
|
||||
Message string // Human-readable description of what went wrong
|
||||
Detail string // Low-level technical detail (shown in debug mode)
|
||||
Suggestion string // Actionable advice for the user
|
||||
Command string // The command that triggered the error (e.g., "issue +create")
|
||||
Cause error // The underlying error
|
||||
}
|
||||
|
||||
func (e *CLIError) Error() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header line: kind + command
|
||||
b.WriteString(string(e.Kind))
|
||||
b.WriteString(" error")
|
||||
if e.Command != "" {
|
||||
b.WriteString(" — ")
|
||||
b.WriteString(e.Command)
|
||||
}
|
||||
|
||||
// Body: message
|
||||
if e.Message != "" {
|
||||
b.WriteString("\n\n reason: ")
|
||||
b.WriteString(e.Message)
|
||||
}
|
||||
|
||||
// Suggestion
|
||||
if e.Suggestion != "" {
|
||||
b.WriteString("\n suggestion: ")
|
||||
b.WriteString(e.Suggestion)
|
||||
}
|
||||
|
||||
// Detail (always included in Error() so users see the raw cause)
|
||||
if e.Detail != "" {
|
||||
b.WriteString("\n detail: ")
|
||||
b.WriteString(e.Detail)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *CLIError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// New creates a CLIError with the given parameters.
|
||||
func New(kind ErrorKind, message, suggestion string) *CLIError {
|
||||
return &CLIError{
|
||||
Kind: kind,
|
||||
Message: message,
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap creates a CLIError that wraps an underlying cause.
|
||||
func Wrap(kind ErrorKind, message, suggestion string, cause error) *CLIError {
|
||||
return &CLIError{
|
||||
Kind: kind,
|
||||
Message: message,
|
||||
Suggestion: suggestion,
|
||||
Cause: cause,
|
||||
Detail: cause.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithCommand sets the command context on the error.
|
||||
func (e *CLIError) WithCommand(cmd string) *CLIError {
|
||||
e.Command = cmd
|
||||
return e
|
||||
}
|
||||
|
||||
// InputError is a convenience constructor for parameter errors.
|
||||
func InputError(message, suggestion string) *CLIError {
|
||||
return New(KindInput, message, suggestion)
|
||||
}
|
||||
|
||||
// AuthError is a convenience constructor for authentication errors.
|
||||
func AuthError(message, suggestion string) *CLIError {
|
||||
return New(KindAuth, message, suggestion)
|
||||
}
|
||||
|
||||
// ConfigError creates a config-related error with the config file path in the suggestion.
|
||||
func ConfigError(message string, cause error) *CLIError {
|
||||
return Wrap(KindConfig, message,
|
||||
fmt.Sprintf("检查配置文件 %s 是否正确", configPathPlaceholder()), cause)
|
||||
}
|
||||
|
||||
// configPathPlaceholder avoids circular import; the actual path will be resolved
|
||||
// in output formatting.
|
||||
func configPathPlaceholder() string {
|
||||
return "~/.config/gitlink-cli/config.yaml"
|
||||
}
|
||||
|
|
@ -71,6 +71,11 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
// GitLink API 经常返回 {"count":N, "items":[...]} 这种"包装列表"
|
||||
// 优先解包内层 slice of maps,按列表渲染
|
||||
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
|
||||
return printSliceTable(w, unwrapped)
|
||||
}
|
||||
// For maps with nested structures, prefer JSON
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
|
|
@ -82,6 +87,59 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
}
|
||||
}
|
||||
|
||||
// unwrapSingleListField 检测 map 是否为"包装列表"结构:
|
||||
// 至少包含一个 []interface{}(元素为 map,或为空数组)字段。
|
||||
// 若是,返回该 slice(用于按列表渲染表格);否则返回 nil。
|
||||
//
|
||||
// 优先选择已知列表字段名(projects/webhooks/issues 等),
|
||||
// 若 map 中只有一个 slice of maps 字段,也直接使用。
|
||||
func unwrapSingleListField(m map[string]interface{}) []interface{} {
|
||||
knownListFields := []string{
|
||||
"projects", "webhooks", "issues", "users", "pull_requests",
|
||||
"builds", "releases", "branches", "teams", "members",
|
||||
"orgs", "items", "records", "results", "wikis", "search",
|
||||
}
|
||||
|
||||
// 1. 优先选择已知字段名(即使数组为空,也接受 — 空数组也是合法列表)
|
||||
for _, name := range knownListFields {
|
||||
if s, ok := m[name].([]interface{}); ok {
|
||||
if isSliceOfMaps(s) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 兜底:检测是否只有一个 slice of maps 字段
|
||||
var listField string
|
||||
var listValue []interface{}
|
||||
for k, v := range m {
|
||||
s, ok := v.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !isSliceOfMaps(s) {
|
||||
continue
|
||||
}
|
||||
if listField != "" {
|
||||
// 多个列表字段,无法自动解包
|
||||
return nil
|
||||
}
|
||||
listField = k
|
||||
listValue = s
|
||||
}
|
||||
return listValue
|
||||
}
|
||||
|
||||
// isSliceOfMaps 判断 slice 是否为 map 元素的列表
|
||||
// 空 slice 也算合法列表(用于在 printSliceTable 中触发 "No results" 输出)
|
||||
func isSliceOfMaps(s []interface{}) bool {
|
||||
if len(s) == 0 {
|
||||
return true // 空数组视为列表,printSliceTable 会输出 "No results"
|
||||
}
|
||||
_, ok := s[0].(map[string]interface{})
|
||||
return ok
|
||||
}
|
||||
|
||||
func hasComplexValues(m map[string]interface{}) bool {
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintTable_WrappedEmptyList(t *testing.T) {
|
||||
env := &Envelope{OK: true, Data: map[string]interface{}{
|
||||
"count": 0,
|
||||
"projects": []interface{}{},
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo failed: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "No results") {
|
||||
t.Errorf("expected 'No results', got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_WrappedList(t *testing.T) {
|
||||
env := &Envelope{OK: true, Data: map[string]interface{}{
|
||||
"count": 2,
|
||||
"projects": []interface{}{
|
||||
map[string]interface{}{"id": 1.0, "name": "alpha"},
|
||||
map[string]interface{}{"id": 2.0, "name": "beta"},
|
||||
},
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo failed: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") {
|
||||
t.Errorf("expected alpha/beta in output, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "id") || !strings.Contains(got, "name") {
|
||||
t.Errorf("expected header id/name, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapSingleListField_KnownName(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"count": 2.0,
|
||||
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
|
||||
}
|
||||
got := unwrapSingleListField(m)
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Fatalf("expected slice len=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) {
|
||||
// 两个未知名字的 list 字段 — 无法自动选择,返回 nil
|
||||
m := map[string]interface{}{
|
||||
"foo_list": []interface{}{map[string]interface{}{"id": 1.0}},
|
||||
"bar_list": []interface{}{map[string]interface{}{"id": 2.0}},
|
||||
}
|
||||
if got := unwrapSingleListField(m); got != nil {
|
||||
t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) {
|
||||
// 已知 name 优先 — 即使有其他 list 字段也用 known name
|
||||
m := map[string]interface{}{
|
||||
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
|
||||
"users": []interface{}{map[string]interface{}{"id": 2.0}},
|
||||
}
|
||||
got := unwrapSingleListField(m)
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Fatalf("expected projects slice len=1, got %v", got)
|
||||
}
|
||||
first := got[0].(map[string]interface{})
|
||||
if first["id"] != 1.0 {
|
||||
t.Errorf("expected projects[0].id=1, got %v", first["id"])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitLink CLI 卸载命令
|
||||
* 用法: gitlink-cli-uninstall [--purge]
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
function info(msg) {
|
||||
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function ask(msg) {
|
||||
console.log(`${colors.blue}[ASK]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
// ---------- 删除文件/目录 ----------
|
||||
function removeSync(target) {
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmdirSync(target, { recursive: true });
|
||||
return true;
|
||||
} else {
|
||||
fs.unlinkSync(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除Skills ----------
|
||||
function removeSkills() {
|
||||
step('删除 Skills...');
|
||||
|
||||
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
|
||||
|
||||
if (removeSync(skillsDir)) {
|
||||
info('Skills已删除');
|
||||
} else {
|
||||
warn('Skills目录不存在或删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除配置 ----------
|
||||
function removeConfig(purgeAll = false) {
|
||||
if (!purgeAll) {
|
||||
info('保留配置文件');
|
||||
return;
|
||||
}
|
||||
|
||||
step('删除配置文件...');
|
||||
|
||||
const configPaths = [
|
||||
path.join(os.homedir(), '.gitlink-cli'),
|
||||
path.join(os.homedir(), '.config', 'gitlink-cli'),
|
||||
path.join(os.homedir(), '.gitlink')
|
||||
];
|
||||
|
||||
let removedCount = 0;
|
||||
for (const configPath of configPaths) {
|
||||
if (removeSync(configPath)) {
|
||||
info(`已删除: ${configPath}`);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
info('配置文件已删除');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const purgeAll = args.includes('--purge') || args.includes('-p');
|
||||
const help = args.includes('--help') || args.includes('-h');
|
||||
|
||||
if (help) {
|
||||
console.log('');
|
||||
console.log('GitLink CLI 卸载命令');
|
||||
console.log('');
|
||||
console.log('用法: gitlink-cli-uninstall [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --purge, -p 删除所有文件(包括配置)');
|
||||
console.log(' --help, -h 显示此帮助');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' gitlink-cli-uninstall # 保留配置');
|
||||
console.log(' gitlink-cli-uninstall --purge # 完全删除');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
info('GitLink CLI 卸载');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
step('卸载npm包...');
|
||||
|
||||
try {
|
||||
// 执行npm uninstall
|
||||
if (process.platform === 'win32') {
|
||||
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
|
||||
}
|
||||
} catch (err) {
|
||||
warn('npm卸载命令执行失败,请手动运行: npm uninstall -g @gitlink-ai/cli');
|
||||
}
|
||||
|
||||
// 删除skills
|
||||
removeSkills();
|
||||
|
||||
// 删除配置
|
||||
removeConfig(purgeAll);
|
||||
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
info('卸载完成!');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
if (!purgeAll) {
|
||||
info('以下文件可能需要手动清理:');
|
||||
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
|
||||
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
|
||||
console.log('');
|
||||
info('如需删除,请运行: gitlink-cli-uninstall --purge');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
info('感谢使用 GitLink CLI!');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 运行
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
error(`卸载失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
{
|
||||
"name": "@gitlink-ai/cli",
|
||||
"version": "0.1.13",
|
||||
"version": "0.2.0",
|
||||
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
|
||||
"bin": {
|
||||
"gitlink-cli": "bin/cli.js",
|
||||
"gitlink-cli-install-skills": "bin/install-skills.js"
|
||||
"gitlink-cli-install-skills": "bin/install-skills.js",
|
||||
"gitlink-cli-uninstall": "bin/uninstall.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js",
|
||||
"preuninstall": "node scripts/uninstall.js",
|
||||
"uninstall": "node scripts/uninstall.js",
|
||||
"test": "node test/install.test.js && node test/cli.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitLink CLI npm 卸载脚本
|
||||
* npm preuninstall 钩子自动运行
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// 颜色输出(支持跨平台)
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
function info(msg) {
|
||||
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
// ---------- 删除文件/目录 ----------
|
||||
function removeSync(target) {
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmdirSync(target, { recursive: true });
|
||||
info(`已删除目录: ${target}`);
|
||||
} else {
|
||||
fs.unlinkSync(target);
|
||||
info(`已删除文件: ${target}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
warn(`删除失败: ${target} - ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除Skills ----------
|
||||
function removeSkills() {
|
||||
step('删除 Skills...');
|
||||
|
||||
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
|
||||
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
warn('Skills目录不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 统计skills数量
|
||||
let skillCount = 0;
|
||||
try {
|
||||
const items = fs.readdirSync(skillsDir);
|
||||
skillCount = items.filter(item => {
|
||||
const itemPath = path.join(skillsDir, item);
|
||||
return fs.statSync(itemPath).isDirectory();
|
||||
}).length;
|
||||
} catch (err) {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
info(`找到 ${skillCount} 个Skills`);
|
||||
|
||||
if (removeSync(skillsDir)) {
|
||||
info('Skills已删除');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除配置(可选)----------
|
||||
function removeConfig(purgeAll = false) {
|
||||
if (!purgeAll) {
|
||||
// npm卸载通常不删除配置
|
||||
info('保留配置文件(用户数据)');
|
||||
return;
|
||||
}
|
||||
|
||||
step('删除配置文件...');
|
||||
|
||||
const configPaths = [
|
||||
path.join(os.homedir(), '.gitlink-cli'),
|
||||
path.join(os.homedir(), '.config', 'gitlink-cli'),
|
||||
path.join(os.homedir(), '.gitlink')
|
||||
];
|
||||
|
||||
let removedCount = 0;
|
||||
for (const configPath of configPaths) {
|
||||
if (removeSync(configPath)) {
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
info('配置文件已删除');
|
||||
} else {
|
||||
warn('未找到配置文件');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
function main() {
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
info('GitLink CLI npm 卸载');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
// 检查环境变量
|
||||
const purgeAll = process.env.GITLINK_UNINSTALL_PURGE === 'true' || process.argv.includes('--purge');
|
||||
|
||||
step('开始清理npm安装的文件...');
|
||||
|
||||
// 删除skills
|
||||
removeSkills();
|
||||
|
||||
// 删除配置(如果指定--purge)
|
||||
removeConfig(purgeAll);
|
||||
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
info('卸载完成!');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
if (!purgeAll) {
|
||||
info('以下文件可能需要手动清理:');
|
||||
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
|
||||
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
|
||||
console.log('');
|
||||
info('如需删除,请运行: npm uninstall -g @gitlink-ai/cli --purge');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
info('感谢使用 GitLink CLI!');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 运行
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
error(`卸载失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execSync } = require("child_process");
|
||||
const PACKAGE = require("../package.json");
|
||||
|
||||
const VERSION = PACKAGE.version;
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
const RELEASE_BASE = "https://www.gitlink.org.cn";
|
||||
const REPO_OWNER = "Gitlink";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
red: "\x1b[31m",
|
||||
cyan: "\x1b[36m",
|
||||
blue: "\x1b[34m",
|
||||
};
|
||||
|
||||
function info(msg) {
|
||||
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
function debug(msg) {
|
||||
if (process.env.DEBUG === "true") {
|
||||
console.log(`${colors.blue}[DEBUG]${colors.reset} ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
const goPlatform = platformMap[platform];
|
||||
const goArch = archMap[arch];
|
||||
|
||||
if (!goPlatform || !goArch) {
|
||||
throw new Error(
|
||||
`Unsupported platform: ${platform}-${arch}. ` +
|
||||
`Supported: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64, win32-arm64`
|
||||
);
|
||||
}
|
||||
|
||||
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
|
||||
}
|
||||
|
||||
function getBinaryName(platform) {
|
||||
return platform === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
}
|
||||
|
||||
function getArchiveName(platform, arch) {
|
||||
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
||||
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
|
||||
}
|
||||
|
||||
function fetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const maxRedirects = options.maxRedirects || 5;
|
||||
let redirectCount = 0;
|
||||
|
||||
function doRequest(currentUrl) {
|
||||
const mod = currentUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(currentUrl, (res) => {
|
||||
// Follow redirects
|
||||
if (
|
||||
(res.statusCode === 301 ||
|
||||
res.statusCode === 302 ||
|
||||
res.statusCode === 307 ||
|
||||
res.statusCode === 308) &&
|
||||
res.headers.location
|
||||
) {
|
||||
redirectCount++;
|
||||
if (redirectCount > maxRedirects) {
|
||||
reject(new Error(`Too many redirects (max ${maxRedirects})`));
|
||||
return;
|
||||
}
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith("/")) {
|
||||
const parsed = new URL(currentUrl);
|
||||
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
|
||||
}
|
||||
doRequest(redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} when downloading ${currentUrl}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.pipe(resolve);
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.setTimeout(options.timeout || 30000, () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request timeout: ${currentUrl}`));
|
||||
});
|
||||
}
|
||||
|
||||
doRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
// 下载文件(带重试)
|
||||
async function downloadFile(url, outputPath, maxAttempts = 3) {
|
||||
let attempt = 1;
|
||||
|
||||
while (attempt <= maxAttempts) {
|
||||
try {
|
||||
step(`下载 (尝试 ${attempt}/${maxAttempts}): ${path.basename(url)}`);
|
||||
debug(`URL: ${url}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(outputPath);
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
|
||||
const req = mod.get(url, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = parseInt(res.headers["content-length"], 10);
|
||||
let downloadedSize = 0;
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize) {
|
||||
const progress = ((downloadedSize / totalSize) * 100).toFixed(1);
|
||||
process.stdout.write(`\r下载进度: ${progress}%`);
|
||||
}
|
||||
});
|
||||
|
||||
res.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
process.stdout.write("\r");
|
||||
resolve();
|
||||
});
|
||||
|
||||
file.on("error", (err) => {
|
||||
fs.unlink(outputPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
file.destroy();
|
||||
fs.unlink(outputPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.setTimeout(120000, () => {
|
||||
req.destroy();
|
||||
file.destroy();
|
||||
fs.unlink(outputPath, () => {});
|
||||
reject(new Error("下载超时"));
|
||||
});
|
||||
});
|
||||
|
||||
if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
|
||||
const sizeMB = (fs.statSync(outputPath).size / (1024 * 1024)).toFixed(2);
|
||||
info(`下载成功: ${path.basename(outputPath)} (${sizeMB}MB)`);
|
||||
return true;
|
||||
} else {
|
||||
warn("下载文件为空");
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`下载失败: ${err.message}`);
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
const waitTime = attempt * 2;
|
||||
warn(`等待 ${waitTime}s 后重试...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
error("下载失败,已尝试 ${maxAttempts} 次");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取最新版本
|
||||
async function getLatestVersion() {
|
||||
try {
|
||||
step("检查更新...");
|
||||
const releasesUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
const releases = await fetch(releasesUrl, { json: true, timeout: 30000 });
|
||||
|
||||
if (releases && releases.length > 0) {
|
||||
return releases[0].tag_name;
|
||||
}
|
||||
} catch (err) {
|
||||
debug(`获取版本失败: ${err.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否有更新
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const latestVersion = await getLatestVersion();
|
||||
|
||||
if (!latestVersion) {
|
||||
info("无法获取最新版本信息");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
|
||||
const latest = latestVersion.startsWith("v") ? latestVersion : `v${latestVersion}`;
|
||||
|
||||
info(`当前版本: ${currentVersion}`);
|
||||
info(`最新版本: ${latest}`);
|
||||
|
||||
if (currentVersion === latest) {
|
||||
info("已经是最新版本");
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单的版本比较
|
||||
if (latest > currentVersion) {
|
||||
warn(`发现新版本: ${latest}`);
|
||||
warn("运行 'npm update -g @gitlink-ai/cli' 更新");
|
||||
} else if (latest < currentVersion) {
|
||||
info("当前版本比最新发布版本更新(开发版本)");
|
||||
}
|
||||
} catch (err) {
|
||||
debug(`检查更新失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 安装二进制
|
||||
async function installBinary() {
|
||||
const platform = getPlatformInfo();
|
||||
info(`平台: ${platform.platform}-${platform.arch}`);
|
||||
|
||||
const binaryName = getBinaryName(platform.platform);
|
||||
const archiveName = getArchiveName(platform.platform, platform.arch);
|
||||
const npmBinDir = path.dirname(process.execPath);
|
||||
const installDir = path.join(npmBinDir, "..");
|
||||
|
||||
step(`安装二进制到: ${installDir}`);
|
||||
|
||||
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
|
||||
const binaryUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archiveName}`;
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
try {
|
||||
// 下载
|
||||
const success = await downloadFile(binaryUrl, archivePath);
|
||||
if (!success) {
|
||||
throw new Error("下载失败");
|
||||
}
|
||||
|
||||
// 解压
|
||||
step("解压...");
|
||||
if (platform.isWindows) {
|
||||
const AdmZip = require("adm-zip");
|
||||
const zip = new AdmZip(archivePath);
|
||||
zip.extractAllTo(installDir, true);
|
||||
} else {
|
||||
const tar = require("tar");
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: installDir,
|
||||
strip: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 设置执行权限(Unix)
|
||||
if (!platform.isWindows) {
|
||||
const binaryPath = path.join(installDir, binaryName);
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
fs.chmodSync(binaryPath, "755");
|
||||
}
|
||||
}
|
||||
|
||||
info(`二进制安装成功: ${binaryName}`);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 安装skills
|
||||
async function installSkills() {
|
||||
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
|
||||
const skillsArchive = `${BINARY_NAME}_${version}_skills.zip`;
|
||||
const skillsUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${skillsArchive}`;
|
||||
|
||||
const skillsDir = path.join(os.homedir(), ".gitlink", "skills");
|
||||
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-skills-"));
|
||||
const archivePath = path.join(tmpDir, skillsArchive);
|
||||
|
||||
try {
|
||||
step("安装 skills...");
|
||||
|
||||
const success = await downloadFile(skillsUrl, archivePath);
|
||||
if (!success) {
|
||||
warn("Skills 包下载失败(可稍后手动安装)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 解压
|
||||
const AdmZip = require("adm-zip");
|
||||
const zip = new AdmZip(archivePath);
|
||||
zip.extractAllTo(skillsDir, true);
|
||||
|
||||
info("Skills 安装成功");
|
||||
} catch (err) {
|
||||
warn(`Skills 安装失败: ${err.message}`);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 验证安装
|
||||
function verifyInstallation() {
|
||||
step("验证安装...";
|
||||
|
||||
try {
|
||||
const result = execSync("gitlink-cli version", { encoding: "utf8" });
|
||||
info(`安装成功! ${result.trim()}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
warn("验证命令失败(可能需要重启终端)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log("");
|
||||
console.log(" ╔══════════════════════════════════════╗");
|
||||
console.log(" ║ GitLink CLI npm 安装脚本 ║");
|
||||
console.log(" ╚══════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
await installBinary();
|
||||
await installSkills();
|
||||
verifyInstallation();
|
||||
|
||||
console.log("");
|
||||
info("快速开始:");
|
||||
info(" gitlink-cli auth login # 登录账号");
|
||||
info(" gitlink-cli --help # 查看所有命令");
|
||||
info(" gitlink-cli version # 查看版本信息");
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
error(`安装失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module || process.argv[1].endsWith("update.js")) {
|
||||
// 检查更新
|
||||
if (process.argv.includes("--check")) {
|
||||
(async () => {
|
||||
await checkForUpdate();
|
||||
})();
|
||||
} else {
|
||||
// 安装
|
||||
(async () => {
|
||||
await main();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main,
|
||||
checkForUpdate,
|
||||
installBinary,
|
||||
installSkills,
|
||||
};
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>科研知识图谱 — 2026-07-06</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
|
||||
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 36px 30px; }
|
||||
.header h1 { font-size: 26px; margin-bottom: 6px; }
|
||||
.header .subtitle { opacity: 0.8; font-size: 14px; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 2px 8px rgba(0,0,0,.08); text-align: center; }
|
||||
.card .value { font-size: 32px; font-weight: 700; color: #1a237e; }
|
||||
.card .label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); margin-bottom: 24px; }
|
||||
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
|
||||
#graphChart { width: 100%; height: 600px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
|
||||
th { background: #f5f7fa; color: #555; font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
|
||||
.tag.rising { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag.stable { background: #e3f2fd; color: #1565c0; }
|
||||
.tag.declining { background: #fce4ec; color: #c62828; }
|
||||
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>科研知识图谱</h1>
|
||||
<div class="subtitle">
|
||||
关键词:LLM,Agent —
|
||||
仓库:4 个 —
|
||||
贡献者:0 人 —
|
||||
2026-07-06
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="cards">
|
||||
<div class="card"><div class="value">4</div><div class="label">仓库节点</div></div>
|
||||
<div class="card"><div class="value">0</div><div class="label">贡献者节点</div></div>
|
||||
<div class="card"><div class="value">2</div><div class="label">主题节点</div></div>
|
||||
<div class="card"><div class="value">3</div><div class="label">关系边</div></div>
|
||||
<div class="card"><div class="value">N/A</div><div class="label">最热仓库</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>知识图谱 — 力导向布局</h2>
|
||||
<div id="graphChart"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>热度排行榜</h2>
|
||||
<table id="hotnessTable">
|
||||
<thead><tr><th>排名</th><th>仓库</th><th>热度</th><th>语言</th><th>Stars</th><th>趋势</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
|
||||
|
||||
<script>
|
||||
var graph = echarts.init(document.getElementById('graphChart'));
|
||||
graph.setOption({
|
||||
tooltip: {
|
||||
formatter: function(p) {
|
||||
if (p.dataType === 'edge') return p.data.source + ' → ' + p.data.target + '<br/>' + p.data.evidence;
|
||||
var d = p.data;
|
||||
return '<b>' + d.label + '</b><br/>' + (d.desc || '') + '<br/>' +
|
||||
(d.stars ? 'Stars: ' + d.stars : '') + (d.repo_count ? ' 关联仓库: ' + d.repo_count : '');
|
||||
}
|
||||
},
|
||||
legend: [{
|
||||
data: ['仓库', '贡献者', '主题', '论文', '组织'],
|
||||
orient: 'vertical', right: 10, top: 20
|
||||
}],
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
roam: true,
|
||||
draggable: true,
|
||||
force: {
|
||||
repulsion: 200,
|
||||
edgeLength: [80, 300],
|
||||
layoutAnimation: true
|
||||
},
|
||||
categories: [
|
||||
{ name: '仓库', itemStyle: { color: '#5470c6' }, symbol: 'roundRect' },
|
||||
{ name: '贡献者', itemStyle: { color: '#91cc75' }, symbol: 'circle' },
|
||||
{ name: '主题', itemStyle: { color: '#fac858' }, symbol: 'diamond' },
|
||||
{ name: '论文', itemStyle: { color: '#ee6666' }, symbol: 'triangle' },
|
||||
{ name: '组织', itemStyle: { color: '#73c0de' }, symbol: 'pin' }
|
||||
],
|
||||
data: [{"id":"topic:llm","type":"topic","label":"LLM\n","symbolSize":30,"category":2},{"id":"topic:agent","type":"topic","label":"Agent\n","symbolSize":30,"category":2}],
|
||||
links: [{"source":"repo:agent","target":"repo:ribo-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:doutrip","target":"repo:agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:ribo-agent","target":"repo:wow-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"}],
|
||||
label: { show: true, fontSize: 11, formatter: '{b}' },
|
||||
emphasis: { focus: 'adjacency', label: { fontSize: 14 } },
|
||||
lineStyle: { color: '#ccc', curveness: 0.1 }
|
||||
}]
|
||||
});
|
||||
window.addEventListener('resize', function() { graph.resize(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"metadata": {
|
||||
"generated_at": "2026-07-06T11:51:35+08:00",
|
||||
"search_keywords": [
|
||||
"LLM",
|
||||
"Agent"
|
||||
],
|
||||
"total_repos_scanned": 4,
|
||||
"total_contributors_found": 0,
|
||||
"total_edges_inferred": 3
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "topic:llm",
|
||||
"type": "topic",
|
||||
"label": "LLM\n",
|
||||
"symbolSize": 30,
|
||||
"category": 2
|
||||
},
|
||||
{
|
||||
"id": "topic:agent",
|
||||
"type": "topic",
|
||||
"label": "Agent\n",
|
||||
"symbolSize": 30,
|
||||
"category": 2
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": "repo:agent",
|
||||
"target": "repo:ribo-agent",
|
||||
"type": "related_to",
|
||||
"weight": 0.5,
|
||||
"evidence": "共同主题: Agent\n"
|
||||
},
|
||||
{
|
||||
"source": "repo:doutrip",
|
||||
"target": "repo:agent",
|
||||
"type": "related_to",
|
||||
"weight": 0.5,
|
||||
"evidence": "共同主题: Agent\n"
|
||||
},
|
||||
{
|
||||
"source": "repo:ribo-agent",
|
||||
"target": "repo:wow-agent",
|
||||
"type": "related_to",
|
||||
"weight": 0.5,
|
||||
"evidence": "共同主题: Agent\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>zzx-coder/gitlink-cli — 复现性评分卡</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
|
||||
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 40px 30px; }
|
||||
.header h1 { font-size: 26px; margin-bottom: 6px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
|
||||
.chart { width: 100%; height: 350px; }
|
||||
.grade-circle { text-align: center; padding: 20px; }
|
||||
.grade-letter { font-size: 72px; font-weight: 900; }
|
||||
.grade-A { color: #2e7d32; }
|
||||
.grade-B { color: #558b2f; }
|
||||
.grade-C { color: #f57c00; }
|
||||
.grade-D { color: #e65100; }
|
||||
.grade-F { color: #c62828; }
|
||||
.grade-score { font-size: 24px; color: #888; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
|
||||
th { background: #f5f7fa; color: #555; }
|
||||
.bar { height: 8px; border-radius: 4px; background: #e0e0e0; margin-top: 4px; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; }
|
||||
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>zzx-coder/gitlink-cli — 科研复现性评分卡</h1>
|
||||
<div style="opacity:0.8;font-size:14px;">2026-07-06</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="row">
|
||||
<div class="panel grade-circle">
|
||||
<div class="grade-letter grade-F">F</div>
|
||||
<div class="grade-score">5.0 / 100</div>
|
||||
<div style="margin-top:12px;color:#888;">
|
||||
|
||||
|
||||
|
||||
|
||||
差 — 几乎不可复现
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>维度雷达图</h2>
|
||||
<div id="radarChart" class="chart"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>维度明细</h2>
|
||||
<table>
|
||||
<tr><th>维度</th><th>评分</th><th>权重</th></tr>
|
||||
<tr><td>许可证</td><td>0%</td><td>15%</td></tr>
|
||||
<tr><td>无密钥/PII</td><td>0%</td><td>15%</td></tr>
|
||||
<tr><td>README 完整</td><td>0%</td><td>15%</td></tr>
|
||||
<tr><td>依赖声明</td><td>0%</td><td>15%</td></tr>
|
||||
<tr><td>构建说明</td><td>50%</td><td>10%</td></tr>
|
||||
<tr><td>CI 配置</td><td>0%</td><td>10%</td></tr>
|
||||
<tr><td>测试证据</td><td>0%</td><td>10%</td></tr>
|
||||
<tr><td>数据可用性</td><td>0%</td><td>10%</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>详细评估与改进建议</h2>
|
||||
<table>
|
||||
<tr><th>维度</th><th>评分</th><th>证据</th><th>建议</th></tr>
|
||||
<tr>
|
||||
<td>许可证</td>
|
||||
<td>❌</td>
|
||||
<td>未扫描(无本地仓库)</td>
|
||||
<td>建议添加 MIT/Apache-2.0/GPL-3.0 许可证</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>无密钥/PII</td>
|
||||
<td>⚠️</td>
|
||||
<td>未扫描(无本地仓库)</td>
|
||||
<td>立即移除泄露的密钥,使用环境变量管理敏感信息</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>README 完整</td>
|
||||
<td>❌</td>
|
||||
<td>README 缺失或过于简略</td>
|
||||
<td>补充项目目的、安装、使用、许可和引用章节</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>依赖声明</td>
|
||||
<td>❌</td>
|
||||
<td>无依赖声明</td>
|
||||
<td>添加 package.json/go.mod/requirements.txt 等标准依赖文件</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>构建说明</td>
|
||||
<td>⚠️</td>
|
||||
<td>部分构建说明</td>
|
||||
<td>添加 Makefile/Dockerfile + README 中的构建步骤</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CI 配置</td>
|
||||
<td>❌</td>
|
||||
<td>无 CI 配置</td>
|
||||
<td>配置 GitLink CI 或 GitHub Actions 自动构建和测试</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>测试证据</td>
|
||||
<td>❌</td>
|
||||
<td>无测试证据</td>
|
||||
<td>添加单元测试和集成测试,在 README 中说明如何运行</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>数据可用性</td>
|
||||
<td>❌</td>
|
||||
<td>无数据可用性声明</td>
|
||||
<td>说明数据集来源,提供 Zenodo/Figshare 链接或生成脚本</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
|
||||
|
||||
<script>
|
||||
var radarChart = echarts.init(document.getElementById('radarChart'));
|
||||
radarChart.setOption({
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '许可证', max: 100 },
|
||||
{ name: '无密钥', max: 100 },
|
||||
{ name: 'README', max: 100 },
|
||||
{ name: '依赖', max: 100 },
|
||||
{ name: '构建', max: 100 },
|
||||
{ name: 'CI', max: 100 },
|
||||
{ name: '测试', max: 100 },
|
||||
{ name: '数据', max: 100 }
|
||||
],
|
||||
center: ['50%', '55%'],
|
||||
radius: '70%'
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
name: '复现性',
|
||||
areaStyle: { color: 'rgba(57,73,171,0.3)' },
|
||||
lineStyle: { color: '#3949ab' }
|
||||
}]
|
||||
}]
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>zzx-coder/gitlink-cli — 科研项目洞察报告</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
|
||||
.header { background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%); color: #fff; padding: 40px 30px; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
.header .subtitle { opacity: 0.85; font-size: 14px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||
.card .label { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
|
||||
.card .value { font-size: 28px; font-weight: 700; }
|
||||
.card .value.hot { color: #e53935; }
|
||||
.card .value.warm { color: #f57c00; }
|
||||
.card .value.cool { color: #1565c0; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
|
||||
.chart { width: 100%; height: 350px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
th { background: #f5f7fa; color: #555; font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
|
||||
.tag.lang { background: #e3f2fd; color: #1565c0; }
|
||||
.tag.research { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag.warn { background: #fff3e0; color: #e65100; }
|
||||
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>zzx-coder/gitlink-cli</h1>
|
||||
<div class="subtitle">科研项目洞察报告 — 2026-07-06</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="label">热度评分</div>
|
||||
<div class="value hot">54.3</div>
|
||||
<div class="label">Hot</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Stars</div>
|
||||
<div class="value">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Forks</div>
|
||||
<div class="value">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">贡献者</div>
|
||||
<div class="value">3</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">开放 Issues</div>
|
||||
<div class="value">44</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">PR 合并率</div>
|
||||
<div class="value">50.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="panel">
|
||||
<h2>项目概况</h2>
|
||||
<table>
|
||||
<tr><th>项目名称</th><td>gitlink-cli</td></tr>
|
||||
<tr><th>描述</th><td>No description</td></tr>
|
||||
<tr><th>主要语言</th><td><span class="tag lang">Unknown</span></td></tr>
|
||||
<tr><th>技术栈</th><td><span class="tag lang">Unknown</span></td></tr>
|
||||
<tr><th>创建时间</th><td></td></tr>
|
||||
<tr><th>最后更新</th><td> (365 天前)</td></tr>
|
||||
<tr><th>科研特征</th><td></td></tr>
|
||||
|
||||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>活动概览</h2>
|
||||
<div id="activityChart" class="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="panel">
|
||||
<h2>健康指标</h2>
|
||||
<table>
|
||||
<tr><th>指标</th><th>数值</th><th>状态</th></tr>
|
||||
<tr><td>Issue 总量</td><td>44 开放 / 44 已关闭</td><td><span class="tag warn">需关注</span></td></tr>
|
||||
<tr><td>PR 合并率</td><td>50.0%</td><td><span class="tag warn">需改进</span></td></tr>
|
||||
<tr><td>Release 数</td><td>6</td><td><span class="tag research">已发布</span></td></tr>
|
||||
<tr><td>CI 通过率</td><td>0% (0 次构建)</td><td><span class="tag warn">不稳定</span></td></tr>
|
||||
<tr><td>贡献者数</td><td>3 人</td><td><span class="tag warn">单人项目</span></td></tr>
|
||||
<tr><td>活跃度</td><td>365 天前更新</td><td><span class="tag warn">不活跃</span></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>热度构成</h2>
|
||||
<div id="hotnessChart" class="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
|
||||
|
||||
<script>
|
||||
var hotnessChart = echarts.init(document.getElementById('hotnessChart'));
|
||||
hotnessChart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '75%'],
|
||||
label: { formatter: '{b}\n{d}%' },
|
||||
data: [
|
||||
{ name: 'Stars', value: 0.0, itemStyle: { color: '#5470c6' } },
|
||||
{ name: 'Forks', value: 0.0, itemStyle: { color: '#91cc75' } },
|
||||
{ name: 'Issues', value: 88.0, itemStyle: { color: '#fac858' } },
|
||||
{ name: 'PRs', value: 133.3, itemStyle: { color: '#ee6666' } },
|
||||
{ name: 'Releases', value: 60.0, itemStyle: { color: '#73c0de' } },
|
||||
{ name: 'Recency', value: 10, itemStyle: { color: '#fc8452' } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
var activityChart = echarts.init(document.getElementById('activityChart'));
|
||||
activityChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: ['Issues', 'PRs', 'Releases', 'CI Builds'] },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '开放/进行中', type: 'bar', data: [44, 20, 0, 0], itemStyle: { color: '#fac858' } },
|
||||
{ name: '已完成', type: 'bar', data: [44, 20, 6, 0], itemStyle: { color: '#91cc75' } }
|
||||
]
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "gitlink-cli",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取分支列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -33,6 +33,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
}
|
||||
return fmt.Sprintf("Create branch: %s (from %s)", name, from), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
||||
|
|
@ -41,7 +50,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
|
|
@ -52,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -60,6 +72,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Delete branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -67,13 +84,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("删除分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -81,6 +101,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Protect branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -88,13 +113,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("设置分支保护失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -102,6 +130,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Unprotect branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -109,11 +142,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("取消分支保护失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 CI 构建列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -42,7 +42,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage := ctx.Arg("stage")
|
||||
step := ctx.Arg("step")
|
||||
if stage == "" {
|
||||
|
|
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取构建日志失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -61,6 +64,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "restart",
|
||||
Description: "Restart a build",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
build := ctx.Arg("build")
|
||||
return fmt.Sprintf("Restart build #%s", build), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -68,17 +76,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重启构建失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop a build",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
build := ctx.Arg("build")
|
||||
return fmt.Sprintf("Stop build #%s", build), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -86,11 +102,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("停止构建失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。
|
||||
// 返回 true 表示已识别并处理;false 表示该 error 类型不被识别,应由调用方走原有路径。
|
||||
//
|
||||
// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐,
|
||||
// 使 `--format json` 输出可被 jq 解析的标准 envelope:
|
||||
//
|
||||
// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}}
|
||||
//
|
||||
// 支持的 error 类型:
|
||||
// - *client.APIError : HTTP 错误(404/403/401 等),code = HTTP 状态码
|
||||
// - *clierrors.CLIError : 业务级错误(输入/认证/网络等),code 由 kind 映射
|
||||
//
|
||||
// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。
|
||||
func TryPrintError(err error, format string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
|
||||
_ = output.Print(env, format)
|
||||
return true
|
||||
}
|
||||
|
||||
var cliErr *clierrors.CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
|
||||
_ = output.Print(env, format)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段
|
||||
func kindToCode(kind clierrors.ErrorKind) int {
|
||||
switch kind {
|
||||
case clierrors.KindAuth:
|
||||
return 401
|
||||
case clierrors.KindInput:
|
||||
return 400
|
||||
case clierrors.KindNotFound:
|
||||
return 404
|
||||
case clierrors.KindForbidden:
|
||||
return 403
|
||||
case clierrors.KindNetwork:
|
||||
return 503
|
||||
case clierrors.KindServer:
|
||||
return 500
|
||||
case clierrors.KindConfig:
|
||||
return 500
|
||||
case clierrors.KindGit:
|
||||
return 500
|
||||
default:
|
||||
return 500
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
|
||||
|
|
@ -26,33 +30,70 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
|||
}
|
||||
}
|
||||
|
||||
ctx, err := NewRuntimeContext(flagValues)
|
||||
commandName := parent.Use + " +" + s.Name
|
||||
ctx, err := NewRuntimeContext(flagValues, commandName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.Run(ctx)
|
||||
// Dry-run interception
|
||||
if s.DryRun {
|
||||
if dryRunVal, _ := cmd.Flags().GetBool("dry-run"); dryRunVal {
|
||||
flagValues["dry-run"] = "true"
|
||||
if s.DryRunHint != nil {
|
||||
hint, err := s.DryRunHint(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[dry-run] %s\n", hint)
|
||||
}
|
||||
proceed, err := ConfirmAction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = s.Run(ctx)
|
||||
if err != nil {
|
||||
// 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout,
|
||||
// 让 `--format json` 输出可被 jq 解析的标准结构。
|
||||
// 已识别后返回 ErrSilent:保留非零退出码,但 cmd.Execute 不会再 stderr 重复输出。
|
||||
if TryPrintError(err, ctx.Format) {
|
||||
return cmdutil.ErrSilent
|
||||
}
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
for _, f := range s.Flags {
|
||||
usage := f.Usage
|
||||
if f.Required {
|
||||
usage = usage + " [required]"
|
||||
}
|
||||
if f.Bool {
|
||||
defaultValue, _ := strconv.ParseBool(f.Default)
|
||||
if f.Short != "" {
|
||||
cmd.Flags().BoolP(f.Name, f.Short, defaultValue, f.Usage)
|
||||
cmd.Flags().BoolP(f.Name, f.Short, defaultValue, usage)
|
||||
} else {
|
||||
cmd.Flags().Bool(f.Name, defaultValue, f.Usage)
|
||||
cmd.Flags().Bool(f.Name, defaultValue, usage)
|
||||
}
|
||||
} else if f.Short != "" {
|
||||
cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage)
|
||||
cmd.Flags().StringP(f.Name, f.Short, f.Default, usage)
|
||||
} else {
|
||||
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
||||
}
|
||||
if f.Required {
|
||||
cmd.MarkFlagRequired(f.Name)
|
||||
cmd.Flags().String(f.Name, f.Default, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-register --dry-run flag for shortcuts that support it
|
||||
if s.DryRun {
|
||||
cmd.Flags().Bool("dry-run", false, "Preview the operation without executing")
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
|
|
@ -16,6 +22,8 @@ type Shortcut struct {
|
|||
Name string
|
||||
Description string
|
||||
Flags []Flag
|
||||
DryRun bool // 是否支持 dry-run
|
||||
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
||||
|
|
@ -31,15 +39,18 @@ type Flag struct {
|
|||
|
||||
// RuntimeContext provides helpers for shortcut implementations.
|
||||
type RuntimeContext struct {
|
||||
Client *client.Client
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
Args map[string]string
|
||||
Client *client.Client
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
CommandName string
|
||||
Args map[string]string
|
||||
GatewayBaseURL string
|
||||
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
|
||||
}
|
||||
|
||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
||||
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
|
||||
cli, err := client.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -51,12 +62,20 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
|||
format = "json"
|
||||
}
|
||||
|
||||
gatewayBaseURL := config.DefaultGatewayBaseURL
|
||||
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
|
||||
gatewayBaseURL = cfg.GatewayBaseURL
|
||||
}
|
||||
|
||||
return &RuntimeContext{
|
||||
Client: cli,
|
||||
Owner: cmdutil.Owner,
|
||||
Repo: cmdutil.Repo,
|
||||
Format: format,
|
||||
Args: args,
|
||||
Client: cli,
|
||||
Owner: cmdutil.Owner,
|
||||
Repo: cmdutil.Repo,
|
||||
Format: format,
|
||||
CommandName: commandName,
|
||||
Args: args,
|
||||
GatewayBaseURL: gatewayBaseURL,
|
||||
GatewayHTTPClient: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -109,11 +128,39 @@ func (ctx *RuntimeContext) Arg(name string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// RequireArg returns a flag value or an error if not set.
|
||||
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||
// RequireArg returns a flag value or a CLIError if not set.
|
||||
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
|
||||
v := ctx.Arg(name)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("required flag --%s is missing", name)
|
||||
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
|
||||
if example != "" {
|
||||
suggestion += fmt.Sprintf(",例如:%s", example)
|
||||
}
|
||||
return "", clierrors.InputError(
|
||||
fmt.Sprintf("required flag --%s is missing", name),
|
||||
suggestion,
|
||||
).WithCommand(ctx.CommandName)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// IsDryRun checks the --dry-run flag.
|
||||
func (ctx *RuntimeContext) IsDryRun() bool {
|
||||
return ctx.Arg("dry-run") == "true"
|
||||
}
|
||||
|
||||
// ConfirmAction prompts the user for confirmation when --dry-run is set.
|
||||
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
|
||||
if !ctx.IsDryRun() {
|
||||
return true, nil
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer == "y" || answer == "yes" {
|
||||
return true, nil
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Aborted.")
|
||||
return false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package compliance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "scan",
|
||||
Description: "Full compliance scan (all five modules)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "module", Short: "m", Usage: "Comma-separated modules: license,deps,secrets,exposure,vocab"},
|
||||
},
|
||||
Run: runScan,
|
||||
},
|
||||
{Name: "license", Description: "License compliance check", Run: runLicense},
|
||||
{Name: "deps", Description: "Dependency license check", Run: runDeps},
|
||||
{Name: "secrets", Description: "Hardcoded secrets scan", Run: runSecrets},
|
||||
{Name: "exposure", Description: "PII and network exposure scan", Run: runExposure},
|
||||
{Name: "vocab", Description: "Sensitive vocabulary scan", Run: runVocab},
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
// find git root
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return dir, nil // fallback to cwd
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// runScan is the full scan (all modules).
|
||||
func runScan(ctx *common.RuntimeContext) error {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selected := ctx.Arg("module")
|
||||
var modules []string
|
||||
if selected != "" {
|
||||
modules = parseModules(selected)
|
||||
} else {
|
||||
modules = []string{"secrets", "exposure", "vocab"}
|
||||
}
|
||||
|
||||
// license and deps checks
|
||||
var allFindings []Finding
|
||||
for _, m := range modules {
|
||||
switch m {
|
||||
case "license":
|
||||
allFindings = append(allFindings, checkLicense(root)...)
|
||||
case "deps":
|
||||
allFindings = append(allFindings, checkDeps(root)...)
|
||||
case "secrets", "exposure", "vocab":
|
||||
rules := allRules()[m]
|
||||
allFindings = append(allFindings, scanFiles(root, rules)...)
|
||||
}
|
||||
}
|
||||
|
||||
return outputReport(ctx, allFindings, modules)
|
||||
}
|
||||
|
||||
func runLicense(ctx *common.RuntimeContext) error {
|
||||
root, _ := repoRoot()
|
||||
findings := checkLicense(root)
|
||||
return outputReport(ctx, findings, []string{"license"})
|
||||
}
|
||||
|
||||
func runDeps(ctx *common.RuntimeContext) error {
|
||||
root, _ := repoRoot()
|
||||
findings := checkDeps(root)
|
||||
return outputReport(ctx, findings, []string{"deps"})
|
||||
}
|
||||
|
||||
func runSecrets(ctx *common.RuntimeContext) error {
|
||||
root, _ := repoRoot()
|
||||
findings := scanFiles(root, allRules()["secrets"])
|
||||
return outputReport(ctx, findings, []string{"secrets"})
|
||||
}
|
||||
|
||||
func runExposure(ctx *common.RuntimeContext) error {
|
||||
root, _ := repoRoot()
|
||||
findings := scanFiles(root, allRules()["exposure"])
|
||||
return outputReport(ctx, findings, []string{"exposure"})
|
||||
}
|
||||
|
||||
func runVocab(ctx *common.RuntimeContext) error {
|
||||
root, _ := repoRoot()
|
||||
findings := scanFiles(root, allRules()["vocab"])
|
||||
return outputReport(ctx, findings, []string{"vocab"})
|
||||
}
|
||||
|
||||
func parseModules(s string) []string {
|
||||
var result []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range strings.Split(s, ",") {
|
||||
m = strings.TrimSpace(m)
|
||||
valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
|
||||
if valid[m] && !seen[m] {
|
||||
result = append(result, m)
|
||||
seen[m] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package compliance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// checkLicense performs static license compliance checks (no regex scanning needed).
|
||||
func checkLicense(root string) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
// L-001: LICENSE file existence
|
||||
files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"}
|
||||
found := false
|
||||
for _, name := range files {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
findings = append(findings, Finding{
|
||||
ID: "L-001", Severity: "medium", Module: "license",
|
||||
File: "-", Line: 0,
|
||||
Summary: "缺少 LICENSE 文件",
|
||||
})
|
||||
}
|
||||
|
||||
// L-002: check npm/package.json license vs root LICENSE
|
||||
rootLicense := detectLicense(root)
|
||||
npmLicense := detectNpmLicense(root)
|
||||
if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) {
|
||||
findings = append(findings, Finding{
|
||||
ID: "L-002", Severity: "medium", Module: "license",
|
||||
File: "npm/package.json", Line: 1,
|
||||
Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %s,npm/package.json 声明 %s", rootLicense, npmLicense),
|
||||
})
|
||||
}
|
||||
|
||||
// L-004: placeholder check in LICENSE file
|
||||
for _, name := range files {
|
||||
path := filepath.Join(root, name)
|
||||
if f, err := os.Open(path); err == nil {
|
||||
sc := bufio.NewScanner(f)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
t := sc.Text()
|
||||
if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") ||
|
||||
strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") {
|
||||
findings = append(findings, Finding{
|
||||
ID: "L-004", Severity: "low", Module: "license",
|
||||
File: name, Line: line,
|
||||
Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder])",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func detectLicense(root string) string {
|
||||
for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} {
|
||||
path := filepath.Join(root, name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := string(data)
|
||||
switch {
|
||||
case strings.Contains(text, "Mulan Permissive Software License"):
|
||||
return "MulanPSL-2.0"
|
||||
case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"):
|
||||
return "Apache-2.0"
|
||||
case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"):
|
||||
return "MIT"
|
||||
case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"):
|
||||
return "AGPL-3.0"
|
||||
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"):
|
||||
return "GPL-3.0"
|
||||
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"):
|
||||
return "GPL-2.0"
|
||||
case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"):
|
||||
return "LGPL"
|
||||
case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3:
|
||||
return "BSD-3-Clause"
|
||||
case strings.Contains(text, "BSD"):
|
||||
return "BSD-2-Clause"
|
||||
case strings.Contains(text, "Mozilla Public License"):
|
||||
return "MPL-2.0"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNpmLicense(root string) string {
|
||||
path := filepath.Join(root, "npm", "package.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// simple string search for "license": "xxx"
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(line, "\"license\"") {
|
||||
line = strings.TrimSpace(line)
|
||||
// "license": "Apache-2.0",
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
v := strings.TrimSpace(parts[1])
|
||||
v = strings.Trim(v, "\",")
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkDeps inspects go.mod for copyleft dependencies.
|
||||
func checkDeps(root string) []Finding {
|
||||
var findings []Finding
|
||||
path := filepath.Join(root, "go.mod")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
// no go.mod — not a Go project
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// GPL/AGPL keywords in module names
|
||||
copyleft := []string{"gpl", "agpl", "gnu"}
|
||||
sc := bufio.NewScanner(f)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
text := strings.ToLower(sc.Text())
|
||||
if !strings.Contains(text, "require") && !strings.Contains(text, "require") {
|
||||
continue
|
||||
}
|
||||
// Check lines after "require" block until blank
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// re-read go.mod and check require block
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
inRequire := false
|
||||
for _, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") {
|
||||
inRequire = true
|
||||
continue
|
||||
}
|
||||
if inRequire && trimmed == "" {
|
||||
break
|
||||
}
|
||||
if inRequire && strings.HasPrefix(trimmed, ")") {
|
||||
break
|
||||
}
|
||||
if inRequire {
|
||||
lower := strings.ToLower(trimmed)
|
||||
for _, kw := range copyleft {
|
||||
if strings.Contains(lower, kw) {
|
||||
findings = append(findings, Finding{
|
||||
ID: "D-003", Severity: "high", Module: "deps",
|
||||
File: "go.mod", Line: 0,
|
||||
Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package compliance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// summary holds aggregated stats.
|
||||
type summary struct {
|
||||
Total int `json:"total"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Modules []string `json:"modules"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Summary summary `json:"summary"`
|
||||
}
|
||||
|
||||
func outputReport(ctx *common.RuntimeContext, findings []Finding, modules []string) error {
|
||||
s := summary{}
|
||||
for _, f := range findings {
|
||||
s.Total++
|
||||
switch f.Severity {
|
||||
case "critical": s.Critical++
|
||||
case "high": s.High++
|
||||
case "medium": s.Medium++
|
||||
case "low": s.Low++
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Format == "json" {
|
||||
return ctx.OutputData(reportData{Modules: modules, Findings: findings, Summary: s})
|
||||
}
|
||||
|
||||
// human-readable output
|
||||
fmt.Println()
|
||||
printHR()
|
||||
fmt.Printf(" Compliance Scan Report\n")
|
||||
fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
|
||||
strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
|
||||
printHR()
|
||||
|
||||
if len(findings) == 0 {
|
||||
fmt.Println(" All clear — no issues found.")
|
||||
} else {
|
||||
printFindings(findings)
|
||||
}
|
||||
printHR()
|
||||
return nil
|
||||
}
|
||||
|
||||
func printHR() {
|
||||
fmt.Println(strings.Repeat("─", 60))
|
||||
}
|
||||
|
||||
func printFindings(findings []Finding) {
|
||||
labels := map[string]string{
|
||||
"critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
|
||||
}
|
||||
for _, f := range findings {
|
||||
label := labels[f.Severity]
|
||||
if label == "" {
|
||||
label = f.Severity
|
||||
}
|
||||
fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure output import is used
|
||||
var _ = output.SuccessEnvelope
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package compliance
|
||||
|
||||
import "regexp"
|
||||
|
||||
// allRules returns the complete set of scan rules grouped by module.
|
||||
func allRules() map[string][]scanRule {
|
||||
return map[string][]scanRule{
|
||||
"secrets": secretRules(),
|
||||
"exposure": exposureRules(),
|
||||
"vocab": vocabRules(),
|
||||
}
|
||||
}
|
||||
|
||||
func compileRE(expr string) *regexp.Regexp {
|
||||
return regexp.MustCompile(expr)
|
||||
}
|
||||
|
||||
// ----- secrets (S-001 ~ S-010) -----
|
||||
func secretRules() []scanRule {
|
||||
return []scanRule{
|
||||
{SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil},
|
||||
{SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil},
|
||||
{SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil},
|
||||
{SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}},
|
||||
{SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil},
|
||||
{SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil},
|
||||
{SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}},
|
||||
{SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil},
|
||||
}
|
||||
}
|
||||
|
||||
// ----- exposure (P-001 ~ E-005) -----
|
||||
func exposureRules() []scanRule {
|
||||
return []scanRule{
|
||||
{PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil},
|
||||
{PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil},
|
||||
{EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil},
|
||||
{EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil},
|
||||
{EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil},
|
||||
}
|
||||
}
|
||||
|
||||
// ----- vocab (C-001 ~ C-006) -----
|
||||
func vocabRules() []scanRule {
|
||||
return []scanRule{
|
||||
{CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil},
|
||||
{CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil},
|
||||
{CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil},
|
||||
{CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil},
|
||||
{CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil},
|
||||
{CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil},
|
||||
}
|
||||
}
|
||||
|
||||
func SID(num string) string { return "S-" + num }
|
||||
func PID(num string) string { return "P-" + num }
|
||||
func EID(num string) string { return "E-" + num }
|
||||
func CID(num string) string { return "C-" + num }
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
package compliance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding represents a single scan result.
|
||||
type Finding struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"` // critical, high, medium, low
|
||||
Module string `json:"module"` // license, deps, secrets, exposure, vocab
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// ScanResult holds all findings for a module.
|
||||
type ScanResult struct {
|
||||
Module string `json:"module"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// scanRule defines a pattern to search for.
|
||||
type scanRule struct {
|
||||
ID string
|
||||
Severity string
|
||||
Pattern *regexp.Regexp
|
||||
Summary string
|
||||
Globs []string // file globs to include, empty = all text files
|
||||
}
|
||||
|
||||
// excludedDirs are directories skipped during scanning.
|
||||
var excludedDirs = map[string]bool{
|
||||
"vendor": true, "node_modules": true, ".git": true, ".claude": true,
|
||||
"skills": true, // skill documentation, not project source
|
||||
}
|
||||
|
||||
// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
|
||||
var excludedPaths = map[string]bool{
|
||||
"shortcuts/compliance": true,
|
||||
}
|
||||
|
||||
// excludedExts are file extensions skipped during scanning.
|
||||
var excludedExts = map[string]bool{
|
||||
".exe": true, ".dll": true, ".so": true, ".dylib": true,
|
||||
".bin": true, ".jpg": true, ".jpeg": true, ".png": true,
|
||||
".gif": true, ".ico": true, ".svg": true, ".pdf": true,
|
||||
".zip": true, ".gz": true, ".tgz": true,
|
||||
}
|
||||
|
||||
// excludeFiles are specific files skipped during scanning.
|
||||
var excludeFiles = map[string]bool{
|
||||
"go.sum": true, "package-lock.json": true,
|
||||
}
|
||||
|
||||
// textExts are extensions treated as text files.
|
||||
var textExts = map[string]bool{
|
||||
".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
|
||||
".py": true, ".rb": true, ".java": true, ".c": true, ".h": true,
|
||||
".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true,
|
||||
".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true,
|
||||
".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true,
|
||||
".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true,
|
||||
".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true,
|
||||
".mod": true,
|
||||
}
|
||||
|
||||
func isTextFile(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
return textExts[ext]
|
||||
}
|
||||
|
||||
func shouldSkip(path string, info os.FileInfo) bool {
|
||||
name := info.Name()
|
||||
if info.IsDir() {
|
||||
if excludedDirs[name] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if excludedExts[strings.ToLower(filepath.Ext(name))] {
|
||||
return true
|
||||
}
|
||||
if excludeFiles[name] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// walkFiles walks the repo and yields text file paths (relative to root).
|
||||
func walkFiles(root string) ([]string, error) {
|
||||
var files []string
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if shouldSkip(path, info) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && isTextFile(path) {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
// skip excluded paths (scanner's own source)
|
||||
for prefix := range excludedPaths {
|
||||
if strings.HasPrefix(rel, prefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
files = append(files, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// scanFiles scans files against rules and returns deduplicated findings.
|
||||
// When a line matches multiple rules, only the highest-severity rule is reported.
|
||||
func scanFiles(root string, rules []scanRule) []Finding {
|
||||
files, err := walkFiles(root)
|
||||
if err != nil {
|
||||
return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}}
|
||||
}
|
||||
|
||||
// dedup by file+line, keeping the highest severity
|
||||
sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
|
||||
seen := make(map[string]Finding) // key: "file:line"
|
||||
|
||||
for _, f := range files {
|
||||
for _, rule := range rules {
|
||||
if !ruleMatchesFile(f, rule.Globs) {
|
||||
continue
|
||||
}
|
||||
for _, m := range scanFile(filepath.Join(root, f), rule) {
|
||||
key := fmt.Sprintf("%s:%d", m.File, m.Line)
|
||||
if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] {
|
||||
seen[key] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
for _, f := range seen {
|
||||
findings = append(findings, f)
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func ruleMatchesFile(file string, globs []string) bool {
|
||||
if len(globs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, g := range globs {
|
||||
matched, _ := filepath.Match(g, filepath.Base(file))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scanFile(path string, rule scanRule) []Finding {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var findings []Finding
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
if rule.Pattern.MatchString(scanner.Text()) {
|
||||
findings = append(findings, Finding{
|
||||
ID: rule.ID,
|
||||
Severity: rule.Severity,
|
||||
Module: ruleMod(rule.ID),
|
||||
File: path,
|
||||
Line: lineNum,
|
||||
Summary: rule.Summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func ruleMod(id string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "L-"):
|
||||
return "license"
|
||||
case strings.HasPrefix(id, "D-"):
|
||||
return "deps"
|
||||
case strings.HasPrefix(id, "S-"):
|
||||
return "secrets"
|
||||
case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"):
|
||||
return "exposure"
|
||||
case strings.HasPrefix(id, "C-"):
|
||||
return "vocab"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package contrib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "report",
|
||||
Description: "Generate a contribution report with pie chart",
|
||||
Flags: []common.Flag{
|
||||
{Name: "output", Short: "o", Usage: "Output HTML file path", Default: "contrib-report.html"},
|
||||
{Name: "open", Usage: "Open browser after generating", Bool: true, Default: "true"},
|
||||
},
|
||||
Run: runReport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runReport(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 获取贡献者列表(从 issue 和 PR 数据中提取)
|
||||
contributors, err := fetchContributors(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch contributors: %w", err)
|
||||
}
|
||||
|
||||
// 2. 计算加权贡献分数
|
||||
reportData := calculateScores(contributors, nil, nil)
|
||||
|
||||
// 3. 生成 HTML 报告
|
||||
outputPath := ctx.Arg("output")
|
||||
if err := generateHTML(ctx.Owner, ctx.Repo, reportData, outputPath); err != nil {
|
||||
return fmt.Errorf("generate HTML: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Report generated: %s\n", outputPath)
|
||||
|
||||
// 4. 打开浏览器
|
||||
if ctx.Arg("open") != "false" {
|
||||
if err := openBrowser(outputPath); err != nil {
|
||||
fmt.Printf("Warning: failed to open browser: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// openBrowser 打开浏览器
|
||||
func openBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
default: // linux
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
package contrib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Contributor 贡献者信息
|
||||
type Contributor struct {
|
||||
Login string
|
||||
Name string
|
||||
Commits int
|
||||
Additions int
|
||||
Deletions int
|
||||
Issues int
|
||||
PRs int
|
||||
Score float64
|
||||
}
|
||||
|
||||
// AHP 权重(基于层次分析法计算)
|
||||
const (
|
||||
WeightCommits = 0.143
|
||||
WeightCodeLines = 0.286
|
||||
WeightPRs = 0.071
|
||||
WeightIssues = 0.05
|
||||
WeightIssueSolve = 0.1
|
||||
WeightRelease = 0.1
|
||||
WeightComments = 0.083
|
||||
WeightPRReview = 0.083
|
||||
WeightWiki = 0.083
|
||||
)
|
||||
|
||||
// fetchContributors 从 issue 和 PR 数据中提取贡献者
|
||||
func fetchContributors(ctx *common.RuntimeContext) ([]Contributor, error) {
|
||||
contributorMap := make(map[string]*Contributor)
|
||||
|
||||
// 1. 从 Issue 列表中提取贡献者
|
||||
issues, err := fetchAllIssues(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch issues: %w", err)
|
||||
}
|
||||
for _, issue := range issues {
|
||||
login := issue["login"]
|
||||
name := issue["name"]
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := contributorMap[login]; !exists {
|
||||
contributorMap[login] = &Contributor{
|
||||
Login: login,
|
||||
Name: name,
|
||||
}
|
||||
if contributorMap[login].Name == "" {
|
||||
contributorMap[login].Name = login
|
||||
}
|
||||
}
|
||||
contributorMap[login].Issues++
|
||||
}
|
||||
|
||||
// 2. 从 PR 列表中提取贡献者
|
||||
prs, err := fetchAllPRs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch PRs: %w", err)
|
||||
}
|
||||
for _, pr := range prs {
|
||||
login := pr["login"]
|
||||
name := pr["name"]
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := contributorMap[login]; !exists {
|
||||
contributorMap[login] = &Contributor{
|
||||
Login: login,
|
||||
Name: name,
|
||||
}
|
||||
if contributorMap[login].Name == "" {
|
||||
contributorMap[login].Name = login
|
||||
}
|
||||
}
|
||||
contributorMap[login].PRs++
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
var contributors []Contributor
|
||||
for _, c := range contributorMap {
|
||||
contributors = append(contributors, *c)
|
||||
}
|
||||
|
||||
if len(contributors) == 0 {
|
||||
return nil, fmt.Errorf("no contributors found")
|
||||
}
|
||||
|
||||
return contributors, nil
|
||||
}
|
||||
|
||||
// fetchAllIssues 获取所有 Issue
|
||||
func fetchAllIssues(ctx *common.RuntimeContext) ([]map[string]string, error) {
|
||||
var results []map[string]string
|
||||
page := 1
|
||||
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
q.Set("limit", "100")
|
||||
q.Set("state", "all")
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
issues, ok := data["issues"].([]interface{})
|
||||
if !ok || len(issues) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, item := range issues {
|
||||
issue, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
author, ok := issue["author"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
login := getString(author, "login")
|
||||
name := getString(author, "name")
|
||||
if login != "" {
|
||||
results = append(results, map[string]string{
|
||||
"login": login,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
totalCount := getInt(data, "total_count")
|
||||
if page*100 >= totalCount {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchAllPRs 获取所有 PR
|
||||
func fetchAllPRs(ctx *common.RuntimeContext) ([]map[string]string, error) {
|
||||
var results []map[string]string
|
||||
page := 1
|
||||
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
q.Set("limit", "100")
|
||||
q.Set("state", "all")
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
// PR 列表字段是 "pulls"
|
||||
prs, ok := data["pulls"].([]interface{})
|
||||
if !ok || len(prs) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, item := range prs {
|
||||
pr, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// PR 的作者信息在 issue.author 中
|
||||
login := ""
|
||||
name := ""
|
||||
if issue, ok := pr["issue"].(map[string]interface{}); ok {
|
||||
if author, ok := issue["author"].(map[string]interface{}); ok {
|
||||
login = getString(author, "login")
|
||||
name = getString(author, "name")
|
||||
}
|
||||
}
|
||||
if login != "" {
|
||||
results = append(results, map[string]string{
|
||||
"login": login,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否还有更多页
|
||||
searchCount := getInt(data, "search_count")
|
||||
if page*100 >= searchCount {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// calculateScores 计算加权贡献分数
|
||||
func calculateScores(contributors []Contributor, issueCounts, prCounts map[string]int) []Contributor {
|
||||
// 如果提供了额外的计数,更新贡献者数据
|
||||
if issueCounts != nil {
|
||||
for i := range contributors {
|
||||
c := &contributors[i]
|
||||
if count, ok := issueCounts[c.Login]; ok {
|
||||
c.Issues = count
|
||||
}
|
||||
}
|
||||
}
|
||||
if prCounts != nil {
|
||||
for i := range contributors {
|
||||
c := &contributors[i]
|
||||
if count, ok := prCounts[c.Login]; ok {
|
||||
c.PRs = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 找到各指标的最大值(用于归一化)
|
||||
maxIssues := 0
|
||||
maxPRs := 0
|
||||
|
||||
for i := range contributors {
|
||||
c := &contributors[i]
|
||||
if c.Issues > maxIssues {
|
||||
maxIssues = c.Issues
|
||||
}
|
||||
if c.PRs > maxPRs {
|
||||
maxPRs = c.PRs
|
||||
}
|
||||
}
|
||||
|
||||
// 计算加权分数(归一化后)
|
||||
for i := range contributors {
|
||||
c := &contributors[i]
|
||||
|
||||
// 归一化到 0-1
|
||||
normIssues := 0.0
|
||||
normPRs := 0.0
|
||||
|
||||
if maxIssues > 0 {
|
||||
normIssues = float64(c.Issues) / float64(maxIssues)
|
||||
}
|
||||
if maxPRs > 0 {
|
||||
normPRs = float64(c.PRs) / float64(maxPRs)
|
||||
}
|
||||
|
||||
// 加权求和(只用 issue 和 PR,因为 commits API 不可用)
|
||||
c.Score = normIssues*WeightIssues + normPRs*WeightPRs
|
||||
}
|
||||
|
||||
return contributors
|
||||
}
|
||||
|
||||
// getString 从 map 中获取字符串
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt 从 map 中获取整数
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
package contrib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ReportData 报告数据
|
||||
type ReportData struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Contributors []Contributor
|
||||
ChartLabels template.JS
|
||||
ChartValues template.JS
|
||||
TableRows []TableRow
|
||||
}
|
||||
|
||||
// TableRow 表格行
|
||||
type TableRow struct {
|
||||
Rank int
|
||||
Login string
|
||||
Name string
|
||||
Commits int
|
||||
CodeLines int
|
||||
Issues int
|
||||
PRs int
|
||||
Score float64
|
||||
ScorePct float64
|
||||
}
|
||||
|
||||
// generateHTML 生成 HTML 报告
|
||||
func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error {
|
||||
// 按分数排序
|
||||
sort.Slice(contributors, func(i, j int) bool {
|
||||
return contributors[i].Score > contributors[j].Score
|
||||
})
|
||||
|
||||
// 准备图表数据
|
||||
var labels []string
|
||||
var values []string
|
||||
var tableRows []TableRow
|
||||
|
||||
totalScore := 0.0
|
||||
for _, c := range contributors {
|
||||
totalScore += c.Score
|
||||
}
|
||||
|
||||
for i, c := range contributors {
|
||||
labels = append(labels, fmt.Sprintf("%q", c.Login))
|
||||
values = append(values, fmt.Sprintf("\"%.4f\"", c.Score))
|
||||
|
||||
scorePct := 0.0
|
||||
if totalScore > 0 {
|
||||
scorePct = c.Score / totalScore * 100
|
||||
}
|
||||
|
||||
tableRows = append(tableRows, TableRow{
|
||||
Rank: i + 1,
|
||||
Login: c.Login,
|
||||
Name: c.Name,
|
||||
Commits: c.Commits,
|
||||
CodeLines: c.Additions + c.Deletions,
|
||||
Issues: c.Issues,
|
||||
PRs: c.PRs,
|
||||
Score: c.Score,
|
||||
ScorePct: scorePct,
|
||||
})
|
||||
}
|
||||
|
||||
data := ReportData{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
Contributors: contributors,
|
||||
ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))),
|
||||
ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))),
|
||||
TableRows: tableRows,
|
||||
}
|
||||
|
||||
// 创建 HTML 文件
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 解析并执行模板
|
||||
tmpl, err := template.New("report").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tmpl.Execute(file, data)
|
||||
}
|
||||
|
||||
// joinStrings 连接字符串切片
|
||||
func joinStrings(strs []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range strs {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// htmlTemplate HTML 模板
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>贡献者报告 - {{.Owner}}/{{.Repo}}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 3px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 15px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
th {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
.rank {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.rank-1 { color: #FFD700; }
|
||||
.rank-2 { color: #C0C0C0; }
|
||||
.rank-3 { color: #CD7F32; }
|
||||
.score-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.score-fill {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.score-text {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.user-login {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.weight-info {
|
||||
background: #f8f9ff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.weight-info h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.weight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.weight-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
.weight-label {
|
||||
color: #666;
|
||||
}
|
||||
.weight-value {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>贡献者报告</h1>
|
||||
<p>{{.Owner}}/{{.Repo}} - 团队成员贡献分析</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{len .Contributors}}</div>
|
||||
<div class="stat-label">贡献者总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-commits">0</div>
|
||||
<div class="stat-label">总提交数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-issues">0</div>
|
||||
<div class="stat-label">总 Issue 数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-prs">0</div>
|
||||
<div class="stat-label">总 PR 数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>贡献占比分布</h2>
|
||||
<div id="pieChart" class="chart-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>详细排名</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>成员</th>
|
||||
<th>Commits</th>
|
||||
<th>代码行数</th>
|
||||
<th>Issues</th>
|
||||
<th>PRs</th>
|
||||
<th>贡献分数</th>
|
||||
<th>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TableRows}}
|
||||
<tr>
|
||||
<td class="rank rank-{{.Rank}}">{{.Rank}}</td>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{slice .Login 0 1}}</div>
|
||||
<div>
|
||||
<div class="user-name">{{.Name}}</div>
|
||||
<div class="user-login">@{{.Login}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{.Commits}}</td>
|
||||
<td>{{.CodeLines}}</td>
|
||||
<td>{{.Issues}}</td>
|
||||
<td>{{.PRs}}</td>
|
||||
<td class="score-text">{{printf "%.4f" .Score}}</td>
|
||||
<td>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width: {{printf "%.1f" .ScorePct}}%"></div>
|
||||
</div>
|
||||
<small>{{printf "%.1f" .ScorePct}}%</small>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>AHP 权重说明</h2>
|
||||
<div class="weight-info">
|
||||
<h3>层次分析法 (AHP) 权重分配</h3>
|
||||
<div class="weight-grid">
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Commits</span>
|
||||
<span class="weight-value">14.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">代码行数</span>
|
||||
<span class="weight-value">28.6%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">PR 合并数</span>
|
||||
<span class="weight-value">7.1%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 创建</span>
|
||||
<span class="weight-value">5.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 解决</span>
|
||||
<span class="weight-value">10.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Release</span>
|
||||
<span class="weight-value">10.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 评论</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">PR 评审</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Wiki</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 计算总数
|
||||
let totalCommits = 0;
|
||||
let totalIssues = 0;
|
||||
let totalPRs = 0;
|
||||
{{range .Contributors}}
|
||||
totalCommits += {{.Commits}};
|
||||
totalIssues += {{.Issues}};
|
||||
totalPRs += {{.PRs}};
|
||||
{{end}}
|
||||
document.getElementById('total-commits').textContent = totalCommits;
|
||||
document.getElementById('total-issues').textContent = totalIssues;
|
||||
document.getElementById('total-prs').textContent = totalPRs;
|
||||
|
||||
// 初始化饼图
|
||||
var chart = echarts.init(document.getElementById('pieChart'));
|
||||
var option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
textStyle: {
|
||||
fontSize: 14
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '贡献占比',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%',
|
||||
fontSize: 12
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{{range .Contributors}}
|
||||
{
|
||||
value: {{printf "%.4f" .Score}},
|
||||
name: '{{.Login}}'
|
||||
},
|
||||
{{end}}
|
||||
]
|
||||
}]
|
||||
};
|
||||
chart.setOption(option);
|
||||
|
||||
// 响应式
|
||||
window.addEventListener('resize', function() {
|
||||
chart.resize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
@ -10,32 +10,113 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const closedIssueStatusID = 5
|
||||
// Priority constants
|
||||
const (
|
||||
priorityLow = 1
|
||||
priorityNormal = 2
|
||||
priorityHigh = 3
|
||||
priorityUrgent = 4
|
||||
)
|
||||
|
||||
type batchCloseResult struct {
|
||||
// Status constants
|
||||
const (
|
||||
statusNew = 1
|
||||
statusInProgress = 2
|
||||
statusResolved = 3
|
||||
statusClosed = 5
|
||||
statusRejected = 6
|
||||
)
|
||||
|
||||
// Tracker constants
|
||||
const (
|
||||
trackerBug = 1
|
||||
trackerFeature = 2
|
||||
trackerSupport = 3
|
||||
trackerDoc = 4
|
||||
trackerTest = 5
|
||||
trackerDuplicate = 6
|
||||
trackerQuestion = 7
|
||||
)
|
||||
|
||||
var priorityNames = map[int]string{
|
||||
priorityLow: "low",
|
||||
priorityNormal: "normal",
|
||||
priorityHigh: "high",
|
||||
priorityUrgent: "urgent",
|
||||
}
|
||||
|
||||
var statusNames = map[int]string{
|
||||
statusNew: "new",
|
||||
statusInProgress: "in-progress",
|
||||
statusResolved: "resolved",
|
||||
statusClosed: "closed",
|
||||
statusRejected: "rejected",
|
||||
}
|
||||
|
||||
var trackerNames = map[int]string{
|
||||
trackerBug: "bug",
|
||||
trackerFeature: "feature",
|
||||
trackerSupport: "support",
|
||||
trackerDoc: "doc",
|
||||
trackerTest: "test",
|
||||
trackerDuplicate: "duplicate",
|
||||
trackerQuestion: "question",
|
||||
}
|
||||
|
||||
// Tag name → GitLink tag ID mapping
|
||||
// Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value
|
||||
var tagIDs = map[string]int{
|
||||
"缺陷": 315526,
|
||||
"功能": 315527,
|
||||
"文档": 315533,
|
||||
"重复": 315525,
|
||||
"疑问": 315528,
|
||||
"支持": 315529,
|
||||
"任务": 315530,
|
||||
"测试": 315534,
|
||||
"协助": 315531,
|
||||
"搁置": 315532,
|
||||
}
|
||||
|
||||
// labelNames returns all known tag names from the given mapping.
|
||||
func labelNames(tags map[string]int) string {
|
||||
var names []string
|
||||
for name := range tags {
|
||||
names = append(names, name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// BatchResult is a single item result in a batch operation.
|
||||
type BatchResult 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"`
|
||||
// BatchSummary is the aggregate result of a batch operation.
|
||||
type BatchSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
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 []BatchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
// ---- batch-close ----
|
||||
|
||||
func newBatchCloseShortcut() *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"},
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchClose,
|
||||
}
|
||||
|
|
@ -45,7 +126,6 @@ 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
|
||||
|
|
@ -55,23 +135,23 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchCloseSummary{
|
||||
summary := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "close",
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchCloseResult{Number: number, Action: "close"}
|
||||
result := BatchResult{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 {
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
|
|
@ -86,28 +166,439 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
|
||||
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||
// ---- batch-status ----
|
||||
|
||||
func newBatchStatusShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-status",
|
||||
Description: "Change status for multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true},
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchStatus(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
state := ctx.Arg("state")
|
||||
statusID, err := parseStatus(state)
|
||||
if 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 := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "set-status",
|
||||
Value: state,
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := BatchResult{Number: number, Action: "set-status"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = state
|
||||
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", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- batch-priority ----
|
||||
|
||||
func newBatchPriorityShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-priority",
|
||||
Description: "Change priority for multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true},
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchPriority,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchPriority(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
priority := ctx.Arg("priority")
|
||||
priorityID, err := parsePriority(priority)
|
||||
if 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 := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "set-priority",
|
||||
Value: priority,
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := BatchResult{Number: number, Action: "set-priority"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = priority
|
||||
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", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- batch-assign ----
|
||||
|
||||
func newBatchAssignShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-assign",
|
||||
Description: "Change assignee for multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAssign,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchAssign(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
assignee := ctx.Arg("assignee")
|
||||
|
||||
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 := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "set-assignee",
|
||||
Value: assignee,
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
var assigneeID interface{}
|
||||
if !dryRun {
|
||||
id, err := resolveUserID(ctx, assignee)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
|
||||
}
|
||||
assigneeID = id
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := BatchResult{Number: number, Action: "set-assignee"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "assigned"
|
||||
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", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- batch-label ----
|
||||
|
||||
func newBatchLabelShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-label",
|
||||
Description: "Change tracker label for multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question, or Chinese names (缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置)", Required: true},
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchLabel,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
label := ctx.Arg("label")
|
||||
|
||||
tags, err := resolveIssueTags(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve issue tags: %w", err)
|
||||
}
|
||||
tagID, err := parseLabel(label, tags)
|
||||
if 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 := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "set-label",
|
||||
Value: label,
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := BatchResult{Number: number, Action: "set-label"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = label
|
||||
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", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- shared helpers ----
|
||||
|
||||
// updateIssueField fetches the current issue to preserve subject/description,
|
||||
// then PATCHes with the given fields merged in.
|
||||
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
return fmt.Errorf("fetch issue #%s: %w", number, err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": closedIssueStatusID,
|
||||
}
|
||||
for k, v := range fields {
|
||||
body[k] = v
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("close issue: %w", err)
|
||||
return fmt.Errorf("update issue #%s: %w", number, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveUserID converts a login name to a numeric user ID via the users API.
|
||||
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
|
||||
if id, err := strconv.Atoi(login); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup user %q: %w", login, err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response for user %q", login)
|
||||
}
|
||||
idFloat, ok := data["id"].(float64)
|
||||
if ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
userIDFloat, ok := data["user_id"].(float64)
|
||||
if ok {
|
||||
return int(userIDFloat), nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot determine user ID for %q", login)
|
||||
}
|
||||
|
||||
func parseStatus(state string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "new":
|
||||
return statusNew, nil
|
||||
case "in-progress", "in_progress", "inprogress":
|
||||
return statusInProgress, nil
|
||||
case "resolved":
|
||||
return statusResolved, nil
|
||||
case "closed":
|
||||
return statusClosed, nil
|
||||
case "rejected":
|
||||
return statusRejected, nil
|
||||
default:
|
||||
if id, err := strconv.Atoi(state); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePriority(p string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "low":
|
||||
return priorityLow, nil
|
||||
case "normal":
|
||||
return priorityNormal, nil
|
||||
case "high":
|
||||
return priorityHigh, nil
|
||||
case "urgent":
|
||||
return priorityUrgent, nil
|
||||
default:
|
||||
if id, err := strconv.Atoi(p); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTracker(label string) (int, error) {
|
||||
trimmed := strings.TrimSpace(label)
|
||||
|
||||
// Check Chinese tag names first
|
||||
if id, ok := tagIDs[trimmed]; ok {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "bug":
|
||||
return trackerBug, nil
|
||||
case "feature":
|
||||
return trackerFeature, nil
|
||||
case "support":
|
||||
return trackerSupport, nil
|
||||
case "doc":
|
||||
return trackerDoc, nil
|
||||
case "test":
|
||||
return trackerTest, nil
|
||||
case "duplicate":
|
||||
return trackerDuplicate, nil
|
||||
case "question":
|
||||
return trackerQuestion, nil
|
||||
default:
|
||||
if id, err := strconv.Atoi(label); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, question, or Chinese names (%s)", label, labelNames(tagIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// parseLabel converts a label name to its GitLink tag ID.
|
||||
// tags is the project's name→id mapping from resolveIssueTags.
|
||||
func parseLabel(name string, tags map[string]int) (int, error) {
|
||||
if id, ok := tags[name]; ok && id != 0 {
|
||||
return id, nil
|
||||
}
|
||||
if id, err := strconv.Atoi(name); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
|
||||
}
|
||||
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
if err != nil {
|
||||
|
|
@ -134,7 +625,7 @@ func parseIssueNumbers(value string) ([]string, error) {
|
|||
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)
|
||||
return nil, fmt.Errorf("read CSV: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -142,7 +633,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
|||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
||||
return nil, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -180,7 +671,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
|
|||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
||||
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
|
||||
}
|
||||
if seen[number] {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -0,0 +1,409 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var issueTagCache sync.Map //全局缓存变量
|
||||
|
||||
// 获取项目标签映射
|
||||
// resolveIssueTags fetches the project's issue tags and returns a name→id mapping.
|
||||
// Results are cached per owner/repo.
|
||||
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
if cached, ok := issueTagCache.Load(key); ok { //先从缓存查
|
||||
return cached.(map[string]int), nil // 命中缓存则直接返回
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) //api路径
|
||||
q := url.Values{}
|
||||
q.Set("only_name", "true")
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q) // 发送GET请求获取项目标签列表
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("标签列表响应格式异常")
|
||||
}
|
||||
|
||||
rawTags, ok := data["issue_tags"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
|
||||
}
|
||||
|
||||
tags := make(map[string]int, len(rawTags))
|
||||
for _, item := range rawTags {
|
||||
tag, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := tag["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
switch v := tag["id"].(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
tags[name] = id
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
|
||||
}
|
||||
|
||||
issueTagCache.Store(key, tags)
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-create",
|
||||
Description: "Create multiple issues from CLI flags or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "titles", Usage: "Comma-separated issue titles, e.g. 标题1,标题2"},
|
||||
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"},
|
||||
{Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
|
||||
{Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"},
|
||||
{Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
|
||||
type createIssueInput struct {
|
||||
Title string
|
||||
Body string
|
||||
Priority string
|
||||
Label string
|
||||
Assignee string
|
||||
Status string
|
||||
// template-specific fields
|
||||
Version string
|
||||
Severity string
|
||||
Steps string
|
||||
Expected string
|
||||
Actual string
|
||||
UserStory string
|
||||
Acceptance string
|
||||
}
|
||||
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tags, err := resolveIssueTags(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
|
||||
|
||||
// Collect inputs from --titles and/or --from
|
||||
var inputs []createIssueInput
|
||||
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
|
||||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||||
}
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs = append(inputs, csvInputs...)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return fmt.Errorf("no issue titles provided; use --titles 标题1,标题2 or --from issues.csv")
|
||||
}
|
||||
|
||||
// Apply CLI --state as fallback for inputs without an explicit status
|
||||
cliState := ctx.Arg("state")
|
||||
for i := range inputs {
|
||||
if inputs[i].Status == "" {
|
||||
inputs[i].Status = cliState
|
||||
}
|
||||
}
|
||||
|
||||
summary := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "create",
|
||||
Value: template,
|
||||
DryRun: dryRun,
|
||||
Total: len(inputs),
|
||||
Results: make([]BatchResult, 0, len(inputs)),
|
||||
}
|
||||
|
||||
for i, input := range inputs {
|
||||
label := fmt.Sprintf("#%d", i+1)
|
||||
if input.Title != "" {
|
||||
label = truncate(input.Title, 40) // 用标题的前40个字符
|
||||
}
|
||||
result := BatchResult{Number: label, Action: "create"}
|
||||
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
//把 createIssueInput 转换成 API 需要的 JSON map
|
||||
body := buildCreateBody(ctx, input, template, tags)
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "created"
|
||||
if data, ok := env.Data.(map[string]interface{}); ok {
|
||||
if num, ok := data["project_issues_index"]; ok {
|
||||
result.Number = fmt.Sprintf("%v", num)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
|
||||
statusID := statusNew
|
||||
if input.Status != "" {
|
||||
if sid, err := parseStatus(input.Status); err == nil {
|
||||
statusID = sid
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"subject": input.Title,
|
||||
"status_id": statusID,
|
||||
"priority_id": priorityNormal,
|
||||
"done_ratio": 0,
|
||||
}
|
||||
|
||||
if template != "" {
|
||||
body["description"] = buildTemplateDescription(input, template)
|
||||
if template == "bug" {
|
||||
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
|
||||
} else if template == "feature" {
|
||||
body["issue_tag_ids"] = []interface{}{tags["功能"]}
|
||||
}
|
||||
} else if input.Body != "" {
|
||||
body["description"] = input.Body
|
||||
}
|
||||
|
||||
if input.Priority != "" {
|
||||
if pid, err := parsePriority(input.Priority); err == nil {
|
||||
body["priority_id"] = pid
|
||||
}
|
||||
}
|
||||
if input.Label != "" {
|
||||
if tid, err := parseLabel(input.Label, tags); err == nil {
|
||||
body["issue_tag_ids"] = []interface{}{tid}
|
||||
}
|
||||
}
|
||||
if input.Assignee != "" {
|
||||
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
|
||||
body["assigner_ids"] = []interface{}{id}
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func buildTemplateDescription(input createIssueInput, template string) string {
|
||||
switch template {
|
||||
case "bug":
|
||||
return buildBugDescription(input)
|
||||
case "feature":
|
||||
return buildFeatureDescription(input)
|
||||
default:
|
||||
return input.Body
|
||||
}
|
||||
}
|
||||
|
||||
func buildBugDescription(input createIssueInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## Bug 描述\n")
|
||||
b.WriteString(input.Title)
|
||||
b.WriteString("\n")
|
||||
|
||||
if input.Version != "" {
|
||||
b.WriteString("\n## 版本\n")
|
||||
b.WriteString(input.Version)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Severity != "" {
|
||||
b.WriteString("\n## 严重程度\n")
|
||||
b.WriteString(input.Severity)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Steps != "" {
|
||||
b.WriteString("\n## 复现步骤\n")
|
||||
b.WriteString(input.Steps)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Expected != "" {
|
||||
b.WriteString("\n## 期望结果\n")
|
||||
b.WriteString(input.Expected)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Actual != "" {
|
||||
b.WriteString("\n## 实际结果\n")
|
||||
b.WriteString(input.Actual)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildFeatureDescription(input createIssueInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 用户故事\n")
|
||||
if input.UserStory != "" {
|
||||
b.WriteString(input.UserStory)
|
||||
} else {
|
||||
b.WriteString(input.Title)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if input.Body != "" {
|
||||
b.WriteString("\n## 描述\n")
|
||||
b.WriteString(input.Body)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Acceptance != "" {
|
||||
b.WriteString("\n## 验收标准\n")
|
||||
b.WriteString(input.Acceptance)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.Priority != "" {
|
||||
b.WriteString("\n## 优先级\n")
|
||||
b.WriteString(input.Priority)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 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, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
col := make(map[string]int)
|
||||
for i, h := range header {
|
||||
col[normalizeHeader(h)] = i
|
||||
}
|
||||
|
||||
if _, ok := col["title"]; !ok {
|
||||
return nil, fmt.Errorf("CSV must have a 'title' column")
|
||||
}
|
||||
|
||||
var inputs []createIssueInput
|
||||
for _, record := range records[1:] {
|
||||
input := createIssueInput{
|
||||
Title: getCol(record, col, "title"),
|
||||
Body: getCol(record, col, "body"),
|
||||
Priority: getCol(record, col, "priority"),
|
||||
Label: getCol(record, col, "label"),
|
||||
Assignee: getCol(record, col, "assignee"),
|
||||
Status: getCol(record, col, "status"),
|
||||
Version: getCol(record, col, "version"),
|
||||
Severity: getCol(record, col, "severity"),
|
||||
Steps: getCol(record, col, "steps"),
|
||||
Expected: getCol(record, col, "expected"),
|
||||
Actual: getCol(record, col, "actual"),
|
||||
// Support alternate heading for feature template
|
||||
UserStory: getCol(record, col, "user_story"),
|
||||
Acceptance: getCol(record, col, "acceptance"),
|
||||
}
|
||||
if input.UserStory == "" {
|
||||
input.UserStory = getCol(record, col, "user story")
|
||||
}
|
||||
if input.Title == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
|
||||
parts := strings.Split(titlesStr, ",")
|
||||
inputs := make([]createIssueInput, 0, len(parts))
|
||||
for _, title := range parts {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, createIssueInput{
|
||||
Title: title,
|
||||
Priority: ctx.Arg("priority"),
|
||||
Label: ctx.Arg("label"),
|
||||
Assignee: ctx.Arg("assignee"),
|
||||
Status: ctx.Arg("state"),
|
||||
})
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func normalizeHeader(h string) string {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
|
||||
func getCol(record []string, col map[string]int, name string) string {
|
||||
if idx, ok := col[name]; ok && idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
|
|
@ -0,0 +1,678 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testTags is a static name→id mapping used by unit tests.
|
||||
var testTags = map[string]int{
|
||||
"缺陷": 315526,
|
||||
"功能": 315527,
|
||||
"文档": 315533,
|
||||
"任务": 315530,
|
||||
"测试": 315534,
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// mockTagsHandler returns a handler that responds to the issue_tags API.
|
||||
func mockTagsHandler(t *testing.T) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tags := make([]map[string]interface{}, 0, len(testTags))
|
||||
for name, id := range testTags {
|
||||
tags = append(tags, map[string]interface{}{
|
||||
"id": float64(id),
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
writeJSONResp(t, w, map[string]interface{}{"issue_tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error {
|
||||
t.Helper()
|
||||
s := findShortcut(t, "batch-create")
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func decodeReqBody(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("decode body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// ---- runBatchCreate tests ----
|
||||
|
||||
func TestBatchCreate_DryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
|
||||
mockTagsHandler(t)(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"titles": "标题1,标题2",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_FromTitles(t *testing.T) {
|
||||
var createdBodies []map[string]interface{}
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
|
||||
mockTagsHandler(t)(w, r)
|
||||
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
|
||||
callCount++
|
||||
body := decodeReqBody(t, r)
|
||||
createdBodies = append(createdBodies, body)
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"project_issues_index": float64(100 + callCount),
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"titles": "Bug修复,功能开发",
|
||||
"state": "new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
if createdBodies[0]["subject"] != "Bug修复" {
|
||||
t.Fatalf("first title: got %q, want %q", createdBodies[0]["subject"], "Bug修复")
|
||||
}
|
||||
if createdBodies[1]["subject"] != "功能开发" {
|
||||
t.Fatalf("second title: got %q, want %q", createdBodies[1]["subject"], "功能开发")
|
||||
}
|
||||
// Verify required fields — values come through JSON as float64
|
||||
for i, body := range createdBodies {
|
||||
if body["done_ratio"] != float64(0) {
|
||||
t.Fatalf("body[%d]: done_ratio = %v (type %T), want 0", i, body["done_ratio"], body["done_ratio"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_NoTitles(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
|
||||
mockTagsHandler(t)(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_FromCSV(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "title,priority,label,status\nCSV标题1,high,缺陷,new\nCSV标题2,normal,功能,new\n")
|
||||
|
||||
var created []map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
|
||||
mockTagsHandler(t)(w, r)
|
||||
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
|
||||
created = append(created, decodeReqBody(t, r))
|
||||
writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"from": csvPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("expected 2 creates, got %d", len(created))
|
||||
}
|
||||
if created[0]["subject"] != "CSV标题1" {
|
||||
t.Fatalf("first subject: got %q", created[0]["subject"])
|
||||
}
|
||||
if created[1]["subject"] != "CSV标题2" {
|
||||
t.Fatalf("second subject: got %q", created[1]["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_CSVMissingTitleColumn(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name,description\nval1,desc1\n")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
|
||||
mockTagsHandler(t)(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing title column, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_CSVOnlyHeader(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "title,description\n")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
|
||||
mockTagsHandler(t)(w, r)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for header-only CSV, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_PartialFailure(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
|
||||
mockTagsHandler(t)(w, r)
|
||||
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(callCount)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"titles": "ok1,fail1,ok2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from partial failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to create") {
|
||||
t.Fatalf("error should mention failed count, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildCreateBody tests (direct call, values retain Go types) ----
|
||||
|
||||
func intVal(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case float64:
|
||||
return int(n)
|
||||
}
|
||||
return -999
|
||||
}
|
||||
|
||||
func TestBuildCreateBody_Basic(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{Title: "Test issue", Status: "new"}
|
||||
body := buildCreateBody(ctx, input, "", testTags)
|
||||
if body["subject"] != "Test issue" {
|
||||
t.Fatalf("subject: got %v", body["subject"])
|
||||
}
|
||||
if intVal(body["done_ratio"]) != 0 {
|
||||
t.Fatalf("done_ratio: got %v (%T), want 0", body["done_ratio"], body["done_ratio"])
|
||||
}
|
||||
if intVal(body["status_id"]) != 1 {
|
||||
t.Fatalf("status_id: got %v (%T), want 1", body["status_id"], body["status_id"])
|
||||
}
|
||||
if intVal(body["priority_id"]) != 2 {
|
||||
t.Fatalf("priority_id: got %v (%T), want 2", body["priority_id"], body["priority_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBody_BugTemplate(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{
|
||||
Title: "登录报错",
|
||||
Version: "v2.0",
|
||||
Severity: "严重",
|
||||
Steps: "1. 打开页面\n2. 点击登录",
|
||||
Expected: "正常登录",
|
||||
Actual: "报错 500",
|
||||
}
|
||||
body := buildCreateBody(ctx, input, "bug", testTags)
|
||||
if body["subject"] != "登录报错" {
|
||||
t.Fatalf("subject: got %v", body["subject"])
|
||||
}
|
||||
desc, _ := body["description"].(string)
|
||||
if !strings.Contains(desc, "## Bug 描述") {
|
||||
t.Fatal("bug description missing header")
|
||||
}
|
||||
if !strings.Contains(desc, "v2.0") {
|
||||
t.Fatal("bug description missing version")
|
||||
}
|
||||
if !strings.Contains(desc, "严重") {
|
||||
t.Fatal("bug description missing severity")
|
||||
}
|
||||
if rawTags, ok := body["issue_tag_ids"]; !ok {
|
||||
t.Fatal("bug template missing issue_tag_ids")
|
||||
} else {
|
||||
ids := rawTags.([]interface{})
|
||||
if intVal(ids[0]) != testTags["缺陷"] {
|
||||
t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBody_FeatureTemplate(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{
|
||||
Title: "用户搜索",
|
||||
UserStory: "作为用户,我想搜索内容",
|
||||
Acceptance: "搜索结果正确显示",
|
||||
}
|
||||
body := buildCreateBody(ctx, input, "feature", testTags)
|
||||
desc, _ := body["description"].(string)
|
||||
if !strings.Contains(desc, "## 用户故事") {
|
||||
t.Fatal("feature description missing user story header")
|
||||
}
|
||||
if !strings.Contains(desc, "作为用户") {
|
||||
t.Fatal("feature description missing user story content")
|
||||
}
|
||||
if !strings.Contains(desc, "## 验收标准") {
|
||||
t.Fatal("feature description missing acceptance criteria")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBody_WithPriorityLabel(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{
|
||||
Title: "紧急修复",
|
||||
Priority: "high",
|
||||
Label: "缺陷",
|
||||
}
|
||||
body := buildCreateBody(ctx, input, "", testTags)
|
||||
if intVal(body["priority_id"]) != 3 {
|
||||
t.Fatalf("priority_id: got %v (type %T), want 3 (high)", body["priority_id"], body["priority_id"])
|
||||
}
|
||||
if rawTags, ok := body["issue_tag_ids"]; ok {
|
||||
ids := rawTags.([]interface{})
|
||||
if intVal(ids[0]) != testTags["缺陷"] {
|
||||
t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"])
|
||||
}
|
||||
} else {
|
||||
t.Fatal("missing issue_tag_ids")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildBugDescription tests ----
|
||||
|
||||
func TestBuildBugDescription_AllFields(t *testing.T) {
|
||||
input := createIssueInput{
|
||||
Title: "登录报错",
|
||||
Version: "v2.0",
|
||||
Severity: "严重",
|
||||
Steps: "1. 打开",
|
||||
Expected: "正常",
|
||||
Actual: "500错误",
|
||||
}
|
||||
result := buildBugDescription(input)
|
||||
if !strings.Contains(result, "## Bug 描述") {
|
||||
t.Fatal("missing Bug 描述")
|
||||
}
|
||||
if !strings.Contains(result, "登录报错") {
|
||||
t.Fatal("missing title")
|
||||
}
|
||||
if !strings.Contains(result, "## 版本") {
|
||||
t.Fatal("missing 版本")
|
||||
}
|
||||
if !strings.Contains(result, "## 严重程度") {
|
||||
t.Fatal("missing 严重程度")
|
||||
}
|
||||
if !strings.Contains(result, "## 复现步骤") {
|
||||
t.Fatal("missing 复现步骤")
|
||||
}
|
||||
if !strings.Contains(result, "## 期望结果") {
|
||||
t.Fatal("missing 期望结果")
|
||||
}
|
||||
if !strings.Contains(result, "## 实际结果") {
|
||||
t.Fatal("missing 实际结果")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBugDescription_PartialFields(t *testing.T) {
|
||||
input := createIssueInput{Title: "小问题"}
|
||||
result := buildBugDescription(input)
|
||||
if !strings.Contains(result, "## Bug 描述") {
|
||||
t.Fatal("missing header")
|
||||
}
|
||||
if strings.Contains(result, "## 版本") {
|
||||
t.Fatal("should not have version section")
|
||||
}
|
||||
if strings.Contains(result, "## 严重程度") {
|
||||
t.Fatal("should not have severity section")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildFeatureDescription tests ----
|
||||
|
||||
func TestBuildFeatureDescription_AllFields(t *testing.T) {
|
||||
input := createIssueInput{
|
||||
Title: "搜索功能",
|
||||
UserStory: "作为用户想搜索",
|
||||
Body: "详细描述",
|
||||
Acceptance: "搜索结果正确",
|
||||
Priority: "high",
|
||||
}
|
||||
result := buildFeatureDescription(input)
|
||||
if !strings.Contains(result, "## 用户故事") {
|
||||
t.Fatal("missing user story")
|
||||
}
|
||||
if !strings.Contains(result, "作为用户想搜索") {
|
||||
t.Fatal("missing user story content")
|
||||
}
|
||||
if !strings.Contains(result, "## 描述") {
|
||||
t.Fatal("missing description")
|
||||
}
|
||||
if !strings.Contains(result, "## 验收标准") {
|
||||
t.Fatal("missing acceptance criteria")
|
||||
}
|
||||
if !strings.Contains(result, "## 优先级") {
|
||||
t.Fatal("missing priority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFeatureDescription_FallbackToTitleAsUserStory(t *testing.T) {
|
||||
input := createIssueInput{Title: "搜索功能"}
|
||||
result := buildFeatureDescription(input)
|
||||
if !strings.Contains(result, "搜索功能") {
|
||||
t.Fatal("should fall back to title as user story")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- readCreateInputsFromCSV tests ----
|
||||
|
||||
func TestReadCreateInputsFromCSV_Normal(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,priority,label,status,version,severity,steps,expected,actual\n标题1,high,缺陷,new,v1,严重,,,\n标题2,normal,功能,new,,,,,\n")
|
||||
|
||||
inputs, err := readCreateInputsFromCSV(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 2 {
|
||||
t.Fatalf("got %d inputs, want 2", len(inputs))
|
||||
}
|
||||
if inputs[0].Title != "标题1" {
|
||||
t.Fatalf("first title: got %q", inputs[0].Title)
|
||||
}
|
||||
if inputs[0].Severity != "严重" {
|
||||
t.Fatalf("severity: got %q", inputs[0].Severity)
|
||||
}
|
||||
if inputs[1].Label != "功能" {
|
||||
t.Fatalf("label: got %q", inputs[1].Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCreateInputsFromCSV_MissingTitleColumn(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,description\nval1,desc1\n")
|
||||
_, err := readCreateInputsFromCSV(path, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCreateInputsFromCSV_OnlyHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,priority\n")
|
||||
_, err := readCreateInputsFromCSV(path, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCreateInputsFromCSV_SkipsEmptyTitle(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,priority\n标题1,high\n,normal\n标题2,low\n")
|
||||
inputs, err := readCreateInputsFromCSV(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 2 {
|
||||
t.Fatalf("got %d inputs, want 2 (empty row skipped)", len(inputs))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseTitles tests ----
|
||||
|
||||
func TestParseTitles_CommaSeparated(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Owner: "o", Repo: "r",
|
||||
Args: map[string]string{"priority": "normal"},
|
||||
}
|
||||
inputs := parseTitles("标题1, 标题2, , 标题3", ctx)
|
||||
if len(inputs) != 3 {
|
||||
t.Fatalf("got %d inputs, want 3", len(inputs))
|
||||
}
|
||||
if inputs[0].Title != "标题1" {
|
||||
t.Fatalf("got %q", inputs[0].Title)
|
||||
}
|
||||
if inputs[2].Title != "标题3" {
|
||||
t.Fatalf("got %q", inputs[2].Title)
|
||||
}
|
||||
if inputs[0].Priority != "normal" {
|
||||
t.Fatalf("priority not propagated: got %q", inputs[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- normalizeHeader tests ----
|
||||
|
||||
func TestNormalizeHeader(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Title", "title"},
|
||||
{" PRIORITY ", "priority"},
|
||||
{"user_story", "user_story"},
|
||||
{"User Story", "user story"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeHeader(c.in)
|
||||
if got != c.want {
|
||||
t.Fatalf("normalizeHeader(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getCol tests ----
|
||||
|
||||
func TestGetCol(t *testing.T) {
|
||||
col := map[string]int{"title": 0, "priority": 1}
|
||||
record := []string{"测试标题", "high"}
|
||||
if got := getCol(record, col, "title"); got != "测试标题" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := getCol(record, col, "missing"); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
if got := getCol(record, col, "priority"); got != "high" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- truncate tests ----
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("short", 40); got != "short" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
long := "这是一个很长的标题用来测试截断功能一二三四五六七八九十"
|
||||
got := truncate(long, 10)
|
||||
if len([]rune(got)) > 13 {
|
||||
t.Fatalf("truncated too long: %q (%d runes)", got, len([]rune(got)))
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Fatal("truncated string should end with ...")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- priority/label/status parse helpers ----
|
||||
|
||||
func TestParsePriorityStrings(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"low", 1}, {"normal", 2}, {"high", 3}, {"urgent", 4},
|
||||
{"LOW", 1}, {"High", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := parsePriority(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePriority(%q): %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("parsePriority(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriorityNumeric(t *testing.T) {
|
||||
got, err := parsePriority("5")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != 5 {
|
||||
t.Fatalf("got %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriorityInvalid(t *testing.T) {
|
||||
if _, err := parsePriority("invalid"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelValid(t *testing.T) {
|
||||
id, err := parseLabel("缺陷", testTags)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != testTags["缺陷"] {
|
||||
t.Fatalf("got %d, want %d", id, testTags["缺陷"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelInvalid(t *testing.T) {
|
||||
if _, err := parseLabel("不存在的标签", testTags); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelNumeric(t *testing.T) {
|
||||
id, err := parseLabel("999", testTags)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != 999 {
|
||||
t.Fatalf("got %d, want 999", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelNamesReturnsAll(t *testing.T) {
|
||||
names := labelNames(testTags)
|
||||
if !strings.Contains(names, "缺陷") {
|
||||
t.Fatal("missing 缺陷 in label names")
|
||||
}
|
||||
if !strings.Contains(names, "功能") {
|
||||
t.Fatal("missing 功能 in label names")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildCreateBody status tests ----
|
||||
|
||||
func TestBuildCreateBody_DefaultStatus(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{Title: "t", Status: ""}
|
||||
body := buildCreateBody(ctx, input, "", testTags)
|
||||
if intVal(body["status_id"]) != 1 {
|
||||
t.Fatalf("default status_id: got %v (type %T), want 1", body["status_id"], body["status_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBody_ClosedStatus(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
|
||||
input := createIssueInput{Title: "t", Status: "closed"}
|
||||
body := buildCreateBody(ctx, input, "", testTags)
|
||||
if intVal(body["status_id"]) != 5 {
|
||||
t.Fatalf("closed status_id: got %v (type %T), want 5", body["status_id"], body["status_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- regression ----
|
||||
|
||||
func TestCollectIssueNumbers_FromBatchCreatePerspective(t *testing.T) {
|
||||
got, err := collectIssueNumbers("1,2,3", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"1", "2", "3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,39 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fmt" //格式化字符串
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strconv" //字符串和数字转换
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Ctx
|
||||
// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo}
|
||||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据
|
||||
type existingIssue struct {
|
||||
Subject string
|
||||
Description string
|
||||
}
|
||||
|
||||
// 所有issue命令的注册入口,返回所有issue命令
|
||||
// 在终端敲入issue +list命令时,遍历匹配到列表里的{Name: "list", ...}
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchStatusShortcut(),
|
||||
newBatchPriorityShortcut(),
|
||||
newBatchAssignShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchCreateShortcut(),
|
||||
newLabelAddShortcut(),
|
||||
newLabelRemoveShortcut(),
|
||||
newLabelListShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
@ -30,26 +42,33 @@ func Shortcuts() []*common.Shortcut {
|
|||
{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 {
|
||||
//后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo
|
||||
// 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。
|
||||
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"))
|
||||
if s := ctx.Arg("state"); s != "" {
|
||||
q := url.Values{} // 创建空的URL查询参数容器q,后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page(页码)和 limit(每页条数
|
||||
if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数
|
||||
q.Set("state", s)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
return ctx.Output(env) //按照用户指定的格式(json/html/table...)输出issue列表
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new issue",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
return fmt.Sprintf("Create issue: %s", title), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
||||
|
|
@ -61,7 +80,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
title, err := ctx.RequireArg("title", `--title "Bug: 登录页崩溃"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -82,7 +101,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -97,13 +116,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
number, err := ctx.RequireArg("number", "--number 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("查看 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -111,6 +130,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "close",
|
||||
Description: "Close an issue",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
number := ctx.Arg("number")
|
||||
return fmt.Sprintf("Close issue #%s", number), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
|
|
@ -118,7 +142,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
number, err := ctx.RequireArg("number", "--number 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -133,6 +157,37 @@ func Shortcuts() []*common.Shortcut {
|
|||
"status_id": 5, // 5 = closed
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reopen",
|
||||
Description: "Reopen a closed issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number", "--number 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": 1, // 1 = open
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -142,6 +197,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
number := ctx.Arg("number")
|
||||
return fmt.Sprintf("Update issue #%s", number), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
|
|
@ -152,7 +212,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
number, err := ctx.RequireArg("number", "--number 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -187,7 +247,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -195,6 +255,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to an issue",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
number := ctx.Arg("number")
|
||||
return fmt.Sprintf("Add comment to issue #%s", number), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -203,11 +268,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
number, err := ctx.RequireArg("number", "--number 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
body, err := ctx.RequireArg("body", `--body "可以这样复现..."`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -216,7 +281,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("添加 Issue 评论失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -227,7 +292,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("获取 Issue 信息失败: %w", err)
|
||||
}
|
||||
issueData, ok := getEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newLabelAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-add",
|
||||
Description: "Add labels to an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
{Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
labelsStr, _ := ctx.RequireArg("labels", `--labels "bug,urgent"`)
|
||||
body := map[string]interface{}{
|
||||
"labels": labelsStr,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLabelRemoveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-remove",
|
||||
Description: "Remove a label from an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
{Name: "label", Short: "l", Usage: "Label ID to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
label, _ := ctx.RequireArg("label", "--label bug")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLabelListShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-list",
|
||||
Description: "List labels on an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List milestones",
|
||||
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", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name", `--name "My Name"`)
|
||||
body := map[string]interface{}{
|
||||
"title": name,
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if due := ctx.Arg("due"); due != "" {
|
||||
body["due_date"] = due
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New name"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["title"] = n
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if due := ctx.Arg("due"); due != "" {
|
||||
body["due_date"] = due
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --description, --due is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a milestone",
|
||||
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, _ := ctx.RequireArg("id", "--id 1")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
package onboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// marker is a unique string embedded in welcome comments to detect existing ones.
|
||||
const marker = "<!-- gitlink-cli:onboard -->"
|
||||
|
||||
// tagCache caches tag name→id mappings per owner/repo.
|
||||
var tagCache sync.Map
|
||||
|
||||
// Shortcuts returns the onboarding shortcut group.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "welcome",
|
||||
Description: "Add welcome comments to specific issues or tag-matched issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "issues", Short: "i", Usage: "Comma-separated issue numbers (e.g. 1,3,7)"},
|
||||
{Name: "tag", Short: "t", Usage: "Tag name to match (comma-separated)", Default: "good first issue,help wanted"},
|
||||
{Name: "template", Usage: "Custom welcome message template ({login}, {number}, {subject}, {description})"},
|
||||
{Name: "force", Short: "f", Usage: "Force re-add even if already commented", Bool: true},
|
||||
},
|
||||
DryRun: true,
|
||||
DryRunHint: dryRunHint,
|
||||
Run: runWelcome,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dryRunHint(ctx *common.RuntimeContext) (string, error) {
|
||||
if issues := ctx.Arg("issues"); issues != "" {
|
||||
return fmt.Sprintf("将为 issue #%s 添加新人引导评论", issues), nil
|
||||
}
|
||||
tag := ctx.Arg("tag")
|
||||
if tag == "" {
|
||||
tag = "good first issue,help wanted"
|
||||
}
|
||||
return fmt.Sprintf("将为所有 [%s] 标签的 issue 添加新人引导评论", tag), nil
|
||||
}
|
||||
|
||||
func runWelcome(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --issues takes priority over --tag
|
||||
if issueArg := ctx.Arg("issues"); issueArg != "" {
|
||||
return runWelcomeByIssueNumbers(ctx, issueArg)
|
||||
}
|
||||
|
||||
tagNames := ctx.Arg("tag")
|
||||
if tagNames == "" {
|
||||
tagNames = "good first issue,help wanted"
|
||||
}
|
||||
|
||||
// resolve tag names → ids
|
||||
tagMap, err := resolveTags(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tagIDs []string
|
||||
for _, name := range strings.Split(tagNames, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if id, ok := tagMap[name]; ok {
|
||||
tagIDs = append(tagIDs, strconv.Itoa(id))
|
||||
}
|
||||
}
|
||||
if len(tagIDs) == 0 {
|
||||
return fmt.Errorf("未找到匹配的标签: %s (可用: %v)", tagNames, tagNamesList(tagMap))
|
||||
}
|
||||
|
||||
// fetch open issues with these tags
|
||||
issues, err := fetchTaggedIssues(ctx, tagIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return processIssues(ctx, issues)
|
||||
}
|
||||
|
||||
// runWelcomeByIssueNumbers directly processes specified issue numbers,
|
||||
// skipping tag resolution and tag-based issue fetching.
|
||||
func runWelcomeByIssueNumbers(ctx *common.RuntimeContext, issueArg string) error {
|
||||
issueNums, err := parseIssueNumbers(issueArg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var issues []issueInfo
|
||||
for _, num := range issueNums {
|
||||
info, err := fetchIssueDetail(ctx, num)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 issue #%d 失败: %w", num, err)
|
||||
}
|
||||
issues = append(issues, info)
|
||||
}
|
||||
|
||||
return processIssues(ctx, issues)
|
||||
}
|
||||
|
||||
// parseIssueNumbers parses a comma-separated string of issue numbers.
|
||||
func parseIssueNumbers(s string) ([]int, error) {
|
||||
parts := strings.Split(s, ",")
|
||||
var nums []int
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的 issue 编号: %q (必须是数字)", p)
|
||||
}
|
||||
nums = append(nums, n)
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
return nil, fmt.Errorf("--issues 参数为空")
|
||||
}
|
||||
return nums, nil
|
||||
}
|
||||
|
||||
// fetchIssueDetail fetches a single issue's subject and description by number.
|
||||
func fetchIssueDetail(ctx *common.RuntimeContext, issueNumber int) (issueInfo, error) {
|
||||
path := fmt.Sprintf("/v1/%s/%s/issues/%d", ctx.Owner, ctx.Repo, issueNumber)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return issueInfo{}, err
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
subj := getString(data, "subject")
|
||||
if subj == "" {
|
||||
subj = fmt.Sprintf("issue #%d", issueNumber)
|
||||
}
|
||||
desc := getString(data, "description")
|
||||
return issueInfo{number: issueNumber, subject: subj, description: desc}, nil
|
||||
}
|
||||
|
||||
// processIssues handles the common issue processing loop used by both
|
||||
// --issues and --tag paths.
|
||||
func processIssues(ctx *common.RuntimeContext, issues []issueInfo) error {
|
||||
tmpl := ctx.Arg("template")
|
||||
if tmpl == "" {
|
||||
tmpl = ""
|
||||
}
|
||||
|
||||
type result struct {
|
||||
num int
|
||||
action string
|
||||
msg string
|
||||
}
|
||||
var results []result
|
||||
|
||||
for _, issue := range issues {
|
||||
force := ctx.Arg("force") == "true"
|
||||
if !force && hasWelcomeComment(ctx, issue.number) {
|
||||
results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)})
|
||||
continue
|
||||
}
|
||||
|
||||
// Render per-issue message with issue-specific variables.
|
||||
body := renderComment(ctx, issue, tmpl)
|
||||
|
||||
if ctx.IsDryRun() {
|
||||
fmt.Printf("\n--- 预览 #%d \"%s\" ---\n%s\n---\n", issue.number, issue.subject, body)
|
||||
proceed, err := common.ConfirmAction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
results = append(results, result{issue.number, "skipped", "用户取消"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := addComment(ctx, issue.number, body); err != nil {
|
||||
results = append(results, result{issue.number, "error", err.Error()})
|
||||
} else {
|
||||
results = append(results, result{issue.number, "added", fmt.Sprintf("#%d \"%s\" — 已添加引导评论", issue.number, issue.subject)})
|
||||
}
|
||||
}
|
||||
|
||||
// output
|
||||
added := 0
|
||||
skipped := 0
|
||||
errors := 0
|
||||
for _, r := range results {
|
||||
switch r.action {
|
||||
case "added":
|
||||
added++
|
||||
case "skipped":
|
||||
skipped++
|
||||
case "error":
|
||||
errors++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n完成: 添加 %d, 跳过 %d, 错误 %d\n", added, skipped, errors)
|
||||
for _, r := range results {
|
||||
fmt.Printf(" [%s] %s\n", r.action, r.msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveTags(ctx *common.RuntimeContext) (map[string]int, error) {
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
if cached, ok := tagCache.Load(key); ok {
|
||||
return cached.(map[string]int), nil
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
|
||||
q := url.Values{}
|
||||
q.Set("only_name", "true")
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取标签列表失败: %w", err)
|
||||
}
|
||||
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
raw, _ := data["issue_tags"].([]interface{})
|
||||
tags := make(map[string]int)
|
||||
for _, item := range raw {
|
||||
if t, ok := item.(map[string]interface{}); ok {
|
||||
if name, ok := t["name"].(string); ok && name != "" {
|
||||
switch v := t["id"].(type) {
|
||||
case float64:
|
||||
tags[name] = int(v)
|
||||
case int:
|
||||
tags[name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return nil, fmt.Errorf("项目没有配置任务标签,请先在 GitLink 网页端创建")
|
||||
}
|
||||
tagCache.Store(key, tags)
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func tagNamesList(tags map[string]int) []string {
|
||||
var names []string
|
||||
for n := range tags {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
type issueInfo struct {
|
||||
number int
|
||||
subject string
|
||||
description string
|
||||
}
|
||||
|
||||
func fetchTaggedIssues(ctx *common.RuntimeContext, tagIDs []string) ([]issueInfo, error) {
|
||||
var all []issueInfo
|
||||
page := 1
|
||||
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("state", "open")
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", "100")
|
||||
q.Set("issue_tag_ids", strings.Join(tagIDs, ","))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
issues, _ := data["issues"].([]interface{})
|
||||
if len(issues) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, item := range issues {
|
||||
if issue, ok := item.(map[string]interface{}); ok {
|
||||
all = append(all, issueInfo{
|
||||
number: getInt(issue, "project_issues_index"),
|
||||
subject: getString(issue, "subject"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
total := getInt(data, "total_count")
|
||||
if page*100 >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func hasWelcomeComment(ctx *common.RuntimeContext, issueNumber int) bool {
|
||||
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
|
||||
q := url.Values{}
|
||||
q.Set("limit", "100")
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
journals, _ := data["journals"].([]interface{})
|
||||
for _, j := range journals {
|
||||
if jm, ok := j.(map[string]interface{}); ok {
|
||||
if notes := getString(jm, "notes"); strings.Contains(notes, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addComment(ctx *common.RuntimeContext, issueNumber int, body string) error {
|
||||
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
|
||||
payload := map[string]interface{}{"notes": marker + "\n\n" + body}
|
||||
_, err := ctx.CallAPI("POST", path, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// renderComment renders the comment body for a specific issue.
|
||||
// It uses the --template if provided, otherwise generates an issue-aware default.
|
||||
func renderComment(ctx *common.RuntimeContext, issue issueInfo, customTmpl string) string {
|
||||
tmpl := customTmpl
|
||||
if tmpl == "" {
|
||||
tmpl = defaultTemplate(ctx.Owner, ctx.Repo, issue)
|
||||
}
|
||||
body := strings.NewReplacer(
|
||||
"{login}", ctx.Owner,
|
||||
"{number}", strconv.Itoa(issue.number),
|
||||
"{subject}", issue.subject,
|
||||
"{description}", issue.description,
|
||||
).Replace(tmpl)
|
||||
return body
|
||||
}
|
||||
|
||||
// defaultTemplate returns an issue-aware onboarding message.
|
||||
func defaultTemplate(owner, repo string, issue issueInfo) string {
|
||||
summary := issue.subject
|
||||
if len(issue.description) > 200 {
|
||||
summary = issue.description[:200] + "..."
|
||||
} else if issue.description != "" {
|
||||
summary = issue.description
|
||||
}
|
||||
return fmt.Sprintf(`## 欢迎贡献!:wave:
|
||||
|
||||
感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。
|
||||
|
||||
### :bulb: 关于本 Issue:{subject}
|
||||
|
||||
%s
|
||||
|
||||
### :rocket: 参与步骤
|
||||
1. **Fork 仓库** 并克隆到本地
|
||||
2. 创建新分支:` + "`git checkout -b fix/issue-{number}`" + `
|
||||
3. 参照上方 issue 描述修改代码
|
||||
4. 推送到你的 Fork 后创建 Pull Request
|
||||
|
||||
### :memo: 注意事项
|
||||
- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有)
|
||||
- 如有疑问,欢迎在评论区留言讨论
|
||||
|
||||
期待你的 PR!`, owner, repo, owner, repo, summary, owner, repo)
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// BatchShortcuts 返回组织级批量成员管理 Shortcut
|
||||
func BatchShortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "batch-invite",
|
||||
Description: "Batch invite members to a project (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runOrgBatchInvite,
|
||||
},
|
||||
{
|
||||
Name: "batch-remove",
|
||||
Description: "Batch remove members from a project (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runOrgBatchRemove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runOrgBatchInvite 组织级批量邀请
|
||||
func runOrgBatchInvite(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseBatchUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
return inviteToOrgProjects(ctx, userIDs, dryRun)
|
||||
}
|
||||
|
||||
// runOrgBatchRemove 组织级批量移除
|
||||
func runOrgBatchRemove(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseBatchUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
return removeFromOrgProjects(ctx, userIDs, dryRun)
|
||||
}
|
||||
|
||||
// parseBatchUserIDs 从 --users 或 --from CSV 解析用户 ID 列表
|
||||
func parseBatchUserIDs(ctx *common.RuntimeContext) ([]int, error) {
|
||||
usersStr := ctx.Arg("users")
|
||||
csvFile := ctx.Arg("from")
|
||||
|
||||
if usersStr == "" && csvFile == "" {
|
||||
return nil, fmt.Errorf("must specify --users (comma-separated user IDs) or --from (CSV file path)")
|
||||
}
|
||||
|
||||
var userIDs []int
|
||||
|
||||
if usersStr != "" {
|
||||
ids, err := parseUserIDList(usersStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, ids...)
|
||||
}
|
||||
|
||||
if csvFile != "" {
|
||||
ids, err := parseCSVUserIDs(csvFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, ids...)
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
// parseCSVUserIDs 从 CSV 文件读取 user_id 列
|
||||
func parseCSVUserIDs(filePath string) ([]int, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open CSV file %s: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CSV file: %w", err)
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
// 查找 user_id 列
|
||||
header := records[0]
|
||||
colIdx := -1
|
||||
for i, h := range header {
|
||||
if strings.TrimSpace(h) == "user_id" {
|
||||
colIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if colIdx == -1 {
|
||||
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
|
||||
}
|
||||
|
||||
var ids []int
|
||||
for _, row := range records[1:] {
|
||||
if len(row) <= colIdx {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(row[colIdx])
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ package org
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
shortcuts := []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
|
|
@ -22,7 +24,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -34,11 +36,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id my-org")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看组织失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -51,13 +56,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id my-org")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织成员失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -65,12 +73,20 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create an organization",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Create organization: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", `--name "My Organization"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
|
|
@ -78,11 +94,222 @@ func Shortcuts() []*common.Shortcut {
|
|||
payload["description"] = d
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建组织失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New name"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["name"] = n
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --description is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite",
|
||||
Description: "Invite a member to a project",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
userID := ctx.Arg("user-id")
|
||||
owner := ctx.Arg("owner")
|
||||
repo := ctx.Arg("repo")
|
||||
return fmt.Sprintf("Invite user %s to %s/%s", userID, owner, repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
|
||||
return inviteToOrgProjects(ctx, []int{uid}, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-member",
|
||||
Description: "Remove a member from a project",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
userID := ctx.Arg("user-id")
|
||||
owner := ctx.Arg("owner")
|
||||
repo := ctx.Arg("repo")
|
||||
return fmt.Sprintf("Remove user %s from %s/%s", userID, owner, repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
|
||||
return removeFromOrgProjects(ctx, []int{uid}, false)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 合并批量成员管理命令
|
||||
shortcuts = append(shortcuts, BatchShortcuts()...)
|
||||
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
// inviteToOrgProjects 向指定项目邀请用户
|
||||
func inviteToOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
|
||||
owner, repo, err := resolveOrgProject(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "invite",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", owner, repo), body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "invite",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// removeFromOrgProjects 从指定项目移除用户
|
||||
func removeFromOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
|
||||
owner, repo, err := resolveOrgProject(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "remove",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", owner, repo), body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "remove",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// resolveOrgProject 从 --owner/--repo 解析目标项目
|
||||
func resolveOrgProject(ctx *common.RuntimeContext) (owner, repo string, err error) {
|
||||
owner = ctx.Arg("owner")
|
||||
repo = ctx.Arg("repo")
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", fmt.Errorf("must specify --owner and --repo (e.g., --owner zzx-coder --repo gitlink-cli)")
|
||||
}
|
||||
return owner, repo, nil
|
||||
}
|
||||
|
||||
// parseUserIDList 解析逗号分隔的用户ID字符串
|
||||
func parseUserIDList(input string) ([]int, error) {
|
||||
var ids []int
|
||||
for _, s := range strings.Split(input, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %s", s)
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newApproveShortcut(),
|
||||
newRequestChangesShortcut(),
|
||||
newReviewsShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List pull requests",
|
||||
|
|
@ -30,7 +33,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -38,6 +41,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
head := ctx.Arg("head")
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
}
|
||||
return fmt.Sprintf("Create PR: %s (%s -> %s)", title, head, base), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "PR description"},
|
||||
|
|
@ -48,8 +61,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
title, err := ctx.RequireArg("title", `--title "Fix login crash"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
head, err := ctx.RequireArg("head", `--head feat/new-login`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
|
|
@ -64,7 +83,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -79,17 +98,29 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
return fmt.Sprintf("Merge PR #%s (method: %s)", id, method), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
|
|
@ -98,7 +129,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
|
|
@ -108,7 +142,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("合并 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -116,6 +150,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "close",
|
||||
Description: "Close a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Close PR #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -123,11 +162,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -141,11 +183,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
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)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 文件列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -159,17 +204,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
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)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Add comment to PR #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -178,12 +231,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
body, _ := ctx.RequireArg("body")
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body", `--body "Looks good to me"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -195,7 +254,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newApproveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "approve",
|
||||
Description: "Approve a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
body := map[string]interface{}{
|
||||
"state": "approved",
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
body["body"] = b
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestChangesShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "request-changes",
|
||||
Description: "Request changes on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
body, _ := ctx.RequireArg("body", `--body "Looks good"`)
|
||||
payload := map[string]interface{}{
|
||||
"state": "changes_requested",
|
||||
"body": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newReviewsShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "reviews",
|
||||
Description: "List reviews for a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -6,39 +6,60 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/compliance"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/contrib"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/onboard"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
||||
// "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" // broken: syntax errors
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
||||
"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/team"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
// "milestone": milestone.Shortcuts(), // broken
|
||||
"team": team.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"contrib": contrib.Shortcuts(),
|
||||
"compliance": compliance.Shortcuts(),
|
||||
"onboard": onboard.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
// "milestone": "Milestone operations", // broken
|
||||
"team": "Team operations",
|
||||
"wiki": "Wiki operations",
|
||||
"webhook": "Webhook operations",
|
||||
"contrib": "Contribution report operations",
|
||||
"compliance": "Compliance and security scan operations",
|
||||
"onboard": "New contributor onboarding operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 Release 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -34,6 +34,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a release",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
tag := ctx.Arg("tag")
|
||||
return fmt.Sprintf("Create release: %s (tag: %s)", name, tag), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
||||
|
|
@ -45,8 +51,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, _ := ctx.RequireArg("tag")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
tag, err := ctx.RequireArg("tag", "--tag v1.0.0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name", `--name "Version 1.0.0"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"tag_name": tag,
|
||||
"name": name,
|
||||
|
|
@ -62,7 +74,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 Release 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -77,17 +89,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 Release 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a release",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Delete release #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
|
|
@ -95,25 +115,58 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if delErr != nil {
|
||||
// GitLink API bug: delete succeeds but returns error status.
|
||||
// Verify by checking if the release still exists.
|
||||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if viewErr != nil {
|
||||
// Release no longer exists — delete actually succeeded
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
return fmt.Errorf("删除 Release 失败: %w", delErr)
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a release",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New release name"},
|
||||
{Name: "body", Short: "b", Usage: "New release notes"},
|
||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["name"] = n
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
body["body"] = b
|
||||
}
|
||||
if p := ctx.Arg("prerelease"); p != "" {
|
||||
body["prerelease"] = p == "true"
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --body, --prerelease is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type repoCreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
Private bool
|
||||
}
|
||||
|
||||
type repoBatchResult struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type repoBatchSummary struct {
|
||||
Owner string `json:"owner" yaml:"owner"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
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 []repoBatchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-create",
|
||||
Description: "Create multiple repositories from CLI flags or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "description", Short: "d", Usage: "Shared description for all repos (inline mode)"},
|
||||
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
|
||||
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
var inputs []repoCreateInput
|
||||
|
||||
if namesStr := ctx.Arg("names"); namesStr != "" {
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, repoCreateInput{
|
||||
Name: name,
|
||||
Description: ctx.Arg("description"),
|
||||
Private: ctx.Arg("private") == "true",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvInputs, err := readRepoInputsFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs = append(inputs, csvInputs...)
|
||||
}
|
||||
|
||||
if len(inputs) == 0 {
|
||||
return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv")
|
||||
}
|
||||
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
var login string
|
||||
var userID int
|
||||
if !dryRun {
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ = userData["login"].(string)
|
||||
if login == "" {
|
||||
return fmt.Errorf("cannot determine current user login")
|
||||
}
|
||||
if uid, ok := userData["user_id"].(float64); ok {
|
||||
userID = int(uid)
|
||||
}
|
||||
}
|
||||
|
||||
summary := repoBatchSummary{
|
||||
Owner: login,
|
||||
Action: "create",
|
||||
DryRun: dryRun,
|
||||
Total: len(inputs),
|
||||
Results: make([]repoBatchResult, 0, len(inputs)),
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
result := repoBatchResult{Name: input.Name}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": input.Name,
|
||||
"repository_name": input.Name,
|
||||
"user_id": userID,
|
||||
}
|
||||
if input.Description != "" {
|
||||
body["description"] = input.Description
|
||||
}
|
||||
if input.Private {
|
||||
body["private"] = true
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "created"
|
||||
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 repo(s) failed to create", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 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, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
col := make(map[string]int)
|
||||
for i, h := range header {
|
||||
col[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
}
|
||||
if _, ok := col["name"]; !ok {
|
||||
return nil, fmt.Errorf("CSV must have a 'name' column")
|
||||
}
|
||||
|
||||
var inputs []repoCreateInput
|
||||
for _, record := range records[1:] {
|
||||
name := getCol(record, col, "name")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
private := false
|
||||
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
|
||||
private = true
|
||||
}
|
||||
inputs = append(inputs, repoCreateInput{
|
||||
Name: name,
|
||||
Description: getCol(record, col, "description"),
|
||||
Private: private,
|
||||
})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func getCol(record []string, col map[string]int, name string) string {
|
||||
if idx, ok := col[name]; ok && idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newBatchDeleteShortcut 实现 repo +batch-delete 命令。
|
||||
//
|
||||
// 设计参考 issue +batch-close 与 repo +batch-update:
|
||||
// - --names : 内联逗号分隔的仓库名列表
|
||||
// - --from : CSV 文件路径(只读 name 列,复用 readNamesFromCSV)
|
||||
// - --dry-run : 仅预览不实际删除
|
||||
//
|
||||
// 与单条 repo +delete 的区别:批量操作下没有"当前仓库"语义,
|
||||
// 因此只要求 --owner(不需要 --repo),所有要删除的仓库都位于该 owner 名下。
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "Delete multiple repositories by names or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "dry-run", Usage: "Preview without deleting", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
owner := ctx.Owner
|
||||
if owner == "" {
|
||||
return fmt.Errorf("--owner is required; pass --owner <login> to specify the account that owns the repos")
|
||||
}
|
||||
|
||||
var repoNames []string
|
||||
if namesStr := ctx.Arg("names"); namesStr != "" {
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
repoNames = append(repoNames, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvNames, err := readNamesFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoNames = append(repoNames, csvNames...)
|
||||
}
|
||||
if len(repoNames) == 0 {
|
||||
return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv")
|
||||
}
|
||||
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
summary := repoBatchSummary{
|
||||
Owner: owner,
|
||||
Action: "delete",
|
||||
DryRun: dryRun,
|
||||
Total: len(repoNames),
|
||||
Results: make([]repoBatchResult, 0, len(repoNames)),
|
||||
}
|
||||
|
||||
for _, name := range repoNames {
|
||||
result := repoBatchResult{Name: name}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s", owner, name), nil); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "deleted"
|
||||
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 repo(s) failed to delete", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func runBatchDeleteShortcut(t *testing.T, server *httptest.Server, owner string, args map[string]string) error {
|
||||
t.Helper()
|
||||
s := findShortcut(t, "batch-delete")
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: owner,
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
func TestBatchDelete_DryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "testuser", map[string]string{
|
||||
"names": "repo1,repo2",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_FromNames(t *testing.T) {
|
||||
var deletedPaths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
deletedPaths = append(deletedPaths, r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
|
||||
"names": "test-batch-1,test-batch-2,test-batch-3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(deletedPaths) != 3 {
|
||||
t.Fatalf("expected 3 DELETE calls, got %d", len(deletedPaths))
|
||||
}
|
||||
expected := []string{"/zzx-coder/test-batch-1.json", "/zzx-coder/test-batch-2.json", "/zzx-coder/test-batch-3.json"}
|
||||
for i, p := range deletedPaths {
|
||||
if p != expected[i] {
|
||||
t.Fatalf("path[%d]: got %q, want %q", i, p, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_NoNames(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no repository names provided") {
|
||||
t.Fatalf("error should mention no names, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_NoOwner(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "", map[string]string{
|
||||
"names": "repo1,repo2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--owner is required") {
|
||||
t.Fatalf("error should mention --owner required, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_DryRunNoNamesErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"dry-run": "true"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no names even in dry-run, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_FromCSV(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n")
|
||||
|
||||
var deletedPaths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
deletedPaths = append(deletedPaths, r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"from": csvPath})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(deletedPaths) != 2 {
|
||||
t.Fatalf("expected 2 DELETE calls, got %d", len(deletedPaths))
|
||||
}
|
||||
if deletedPaths[0] != "/zzx-coder/csv-repo1.json" {
|
||||
t.Fatalf("first path: got %q", deletedPaths[0])
|
||||
}
|
||||
if deletedPaths[1] != "/zzx-coder/csv-repo2.json" {
|
||||
t.Fatalf("second path: got %q", deletedPaths[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_CSVAndNamesCombined(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name\ncsv-repo\n")
|
||||
|
||||
var deletedPaths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deletedPaths = append(deletedPaths, r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
|
||||
"names": "inline-repo",
|
||||
"from": csvPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(deletedPaths) != 2 {
|
||||
t.Fatalf("expected 2 deletes (inline + csv), got %d", len(deletedPaths))
|
||||
}
|
||||
if deletedPaths[0] != "/zzx-coder/inline-repo.json" {
|
||||
t.Fatalf("first: got %q", deletedPaths[0])
|
||||
}
|
||||
if deletedPaths[1] != "/zzx-coder/csv-repo.json" {
|
||||
t.Fatalf("second: got %q", deletedPaths[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_PartialFailure(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"repo not found"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
|
||||
"names": "ok1,fail1,ok2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from partial failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to delete") {
|
||||
t.Fatalf("error should mention failed count, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDelete_TrimsWhitespace(t *testing.T) {
|
||||
var deletedPaths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deletedPaths = append(deletedPaths, r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
|
||||
"names": " repo-a , repo-b ,",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(deletedPaths) != 2 {
|
||||
t.Fatalf("expected 2 deletes after trimming, got %d", len(deletedPaths))
|
||||
}
|
||||
if deletedPaths[0] != "/zzx-coder/repo-a.json" {
|
||||
t.Fatalf("first: got %q", deletedPaths[0])
|
||||
}
|
||||
if deletedPaths[1] != "/zzx-coder/repo-b.json" {
|
||||
t.Fatalf("second: got %q", deletedPaths[1])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// BatchMemberShortcuts 返回批量成员管理相关的 Shortcut(由 repo.Shortcuts() 调用合并)
|
||||
func BatchMemberShortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "batch-invite",
|
||||
Description: "Batch invite members to a repository (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runBatchInvite,
|
||||
},
|
||||
{
|
||||
Name: "batch-remove",
|
||||
Description: "Batch remove members from a repository (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runBatchRemove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runBatchInvite 执行批量邀请
|
||||
func runBatchInvite(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "invite",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "invite",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// runBatchRemove 执行批量移除
|
||||
func runBatchRemove(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "remove",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "remove",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// parseUserIDs 从 --users 或 --from 参数中解析用户 ID 列表
|
||||
func parseUserIDs(ctx *common.RuntimeContext) ([]int, error) {
|
||||
usersStr := ctx.Arg("users")
|
||||
csvFile := ctx.Arg("from")
|
||||
|
||||
if usersStr == "" && csvFile == "" {
|
||||
return nil, fmt.Errorf("必须指定 --users(逗号分隔的用户ID)或 --from(CSV文件路径)")
|
||||
}
|
||||
|
||||
var userIDs []int
|
||||
|
||||
if usersStr != "" {
|
||||
for _, s := range strings.Split(usersStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %s", s)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
}
|
||||
|
||||
if csvFile != "" {
|
||||
f, err := os.Open(csvFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open CSV file %s: %w", csvFile, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CSV file: %w", err)
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
// 查找 user_id 列
|
||||
header := records[0]
|
||||
colIdx := -1
|
||||
for i, h := range header {
|
||||
if strings.TrimSpace(h) == "user_id" {
|
||||
colIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if colIdx == -1 {
|
||||
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
|
||||
}
|
||||
|
||||
for _, row := range records[1:] {
|
||||
if len(row) <= colIdx {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(row[colIdx])
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,837 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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 runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error {
|
||||
t.Helper()
|
||||
s := findShortcut(t, "batch-create")
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
func runBatchUpdateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error {
|
||||
t.Helper()
|
||||
s := findShortcut(t, "batch-update")
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("writeJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeReqBody(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("decode body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeTempCSV(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "repos.csv")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp csv: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ---- runBatchCreate tests ----
|
||||
|
||||
func TestBatchCreate_DryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "repo1,repo2",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_FromNames(t *testing.T) {
|
||||
var createdBodies []map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/testuser/"):
|
||||
createdBodies = append(createdBodies, decodeReqBody(t, r))
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "repo-a,repo-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(createdBodies) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(createdBodies))
|
||||
}
|
||||
if createdBodies[0]["name"] != "repo-a" {
|
||||
t.Fatalf("first name: got %q, want %q", createdBodies[0]["name"], "repo-a")
|
||||
}
|
||||
if createdBodies[1]["name"] != "repo-b" {
|
||||
t.Fatalf("second name: got %q, want %q", createdBodies[1]["name"], "repo-b")
|
||||
}
|
||||
for _, body := range createdBodies {
|
||||
if body["repository_name"] != body["name"] {
|
||||
t.Fatalf("repository_name should match name: %v vs %v", body["repository_name"], body["name"])
|
||||
}
|
||||
if body["user_id"] != float64(42) {
|
||||
t.Fatalf("user_id: got %v, want 42", body["user_id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_NoNames(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_NoNamesDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{"dry-run": "true"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no names even in dry-run, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_WithPrivate(t *testing.T) {
|
||||
var createdBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
createdBody = decodeReqBody(t, r)
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "private-repo",
|
||||
"private": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if createdBody["private"] != true {
|
||||
t.Fatalf("private: got %v, want true", createdBody["private"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_WithDescription(t *testing.T) {
|
||||
var createdBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
createdBody = decodeReqBody(t, r)
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "desc-repo",
|
||||
"description": "shared description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if createdBody["description"] != "shared description" {
|
||||
t.Fatalf("description: got %q, want %q", createdBody["description"], "shared description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_FromCSV(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name,description,private\ncsv-repo1,desc one,false\ncsv-repo2,desc two,true\n")
|
||||
|
||||
var createdBodies []map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
createdBodies = append(createdBodies, decodeReqBody(t, r))
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(createdBodies) != 2 {
|
||||
t.Fatalf("expected 2 creates, got %d", len(createdBodies))
|
||||
}
|
||||
if createdBodies[0]["name"] != "csv-repo1" {
|
||||
t.Fatalf("first name: got %q", createdBodies[0]["name"])
|
||||
}
|
||||
if createdBodies[0]["description"] != "desc one" {
|
||||
t.Fatalf("first description: got %q", createdBodies[0]["description"])
|
||||
}
|
||||
if createdBodies[1]["name"] != "csv-repo2" {
|
||||
t.Fatalf("second name: got %q", createdBodies[1]["name"])
|
||||
}
|
||||
if createdBodies[1]["private"] != true {
|
||||
t.Fatalf("second private: got %v, want true", createdBodies[1]["private"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_PartialFailure(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(callCount)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "ok1,fail1,ok2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from partial failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to create") {
|
||||
t.Fatalf("error should mention failed count, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_UserLookupFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "repo1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to get current user") {
|
||||
t.Fatalf("error should mention user lookup, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_UserMissingLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"user_id": float64(42),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "repo1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot determine current user login") {
|
||||
t.Fatalf("error should mention missing login, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_CSVAndNamesCombined(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name\ndual-repo\n")
|
||||
|
||||
var createdNames []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
body := decodeReqBody(t, r)
|
||||
createdNames = append(createdNames, body["name"].(string))
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchCreateShortcut(t, server, map[string]string{
|
||||
"names": "inline-repo",
|
||||
"from": csvPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(createdNames) != 2 {
|
||||
t.Fatalf("expected 2 creates, got %d", len(createdNames))
|
||||
}
|
||||
if createdNames[0] != "inline-repo" {
|
||||
t.Fatalf("first: got %q", createdNames[0])
|
||||
}
|
||||
if createdNames[1] != "dual-repo" {
|
||||
t.Fatalf("second: got %q", createdNames[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- readRepoInputsFromCSV tests ----
|
||||
|
||||
func TestReadRepoInputsFromCSV_Normal(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,description,private\nrepo1,desc1,true\nrepo2,desc2,false\nrepo3,,0\n")
|
||||
|
||||
inputs, err := readRepoInputsFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 3 {
|
||||
t.Fatalf("got %d inputs, want 3", len(inputs))
|
||||
}
|
||||
if inputs[0].Name != "repo1" || inputs[0].Description != "desc1" || !inputs[0].Private {
|
||||
t.Fatalf("input[0]: %+v", inputs[0])
|
||||
}
|
||||
if inputs[1].Name != "repo2" || inputs[1].Private {
|
||||
t.Fatalf("input[1]: %+v", inputs[1])
|
||||
}
|
||||
if inputs[2].Name != "repo3" || inputs[2].Private {
|
||||
t.Fatalf("input[2]: %+v", inputs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_MissingNameColumn(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,description\nval1,desc1\n")
|
||||
_, err := readRepoInputsFromCSV(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_OnlyHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,description\n")
|
||||
_, err := readRepoInputsFromCSV(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_SkipsEmptyName(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,description\nrepo1,desc1\n,desc2\nrepo2,desc3\n")
|
||||
|
||||
inputs, err := readRepoInputsFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 2 {
|
||||
t.Fatalf("got %d inputs, want 2", len(inputs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_PrivateParsing(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,private\nr1,true\nr2,false\nr3,1\nr4,0\nr5,\n")
|
||||
|
||||
inputs, err := readRepoInputsFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 5 {
|
||||
t.Fatalf("got %d inputs, want 5", len(inputs))
|
||||
}
|
||||
if !inputs[0].Private {
|
||||
t.Fatal("r1 (true) should be private")
|
||||
}
|
||||
if inputs[1].Private {
|
||||
t.Fatal("r2 (false) should not be private")
|
||||
}
|
||||
if !inputs[2].Private {
|
||||
t.Fatal("r3 (1) should be private")
|
||||
}
|
||||
if inputs[3].Private {
|
||||
t.Fatal("r4 (0) should not be private")
|
||||
}
|
||||
if inputs[4].Private {
|
||||
t.Fatal("r5 (empty) should not be private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_FileNotFound(t *testing.T) {
|
||||
_, err := readRepoInputsFromCSV("/nonexistent/path.csv")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoInputsFromCSV_CaseInsensitiveHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "NAME,Description,Private\nrepo1,desc1,TRUE\n")
|
||||
|
||||
inputs, err := readRepoInputsFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(inputs) != 1 {
|
||||
t.Fatalf("got %d inputs, want 1", len(inputs))
|
||||
}
|
||||
if inputs[0].Name != "repo1" {
|
||||
t.Fatalf("name: got %q", inputs[0].Name)
|
||||
}
|
||||
if !inputs[0].Private {
|
||||
t.Fatal("should be private")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getCol tests ----
|
||||
|
||||
func TestGetCol_Found(t *testing.T) {
|
||||
col := map[string]int{"name": 0, "description": 1}
|
||||
record := []string{"my-repo", "my desc"}
|
||||
if got := getCol(record, col, "name"); got != "my-repo" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := getCol(record, col, "description"); got != "my desc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCol_Missing(t *testing.T) {
|
||||
col := map[string]int{"name": 0}
|
||||
record := []string{"my-repo"}
|
||||
if got := getCol(record, col, "missing"); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCol_IndexOutOfRange(t *testing.T) {
|
||||
col := map[string]int{"name": 5}
|
||||
record := []string{"my-repo"}
|
||||
if got := getCol(record, col, "name"); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- runBatchUpdate tests ----
|
||||
|
||||
func TestBatchUpdate_DryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "repo1,repo2",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_FromNames(t *testing.T) {
|
||||
var fetchedRepos []string
|
||||
var patchedBodies []map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json")
|
||||
fetchedRepos = append(fetchedRepos, name)
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": name,
|
||||
"identifier": "ident-" + name,
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
patchedBodies = append(patchedBodies, decodeReqBody(t, r))
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "repo-a,repo-b",
|
||||
"private": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fetchedRepos) != 2 {
|
||||
t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos))
|
||||
}
|
||||
if len(patchedBodies) != 2 {
|
||||
t.Fatalf("expected 2 patches, got %d", len(patchedBodies))
|
||||
}
|
||||
for _, body := range patchedBodies {
|
||||
if body["private"] != true {
|
||||
t.Fatalf("private should be true: %+v", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_NoNames(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_PrivatePublicConflict(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "repo1",
|
||||
"private": "true",
|
||||
"public": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot use both --private and --public") {
|
||||
t.Fatalf("error should mention conflict, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_SetPublic(t *testing.T) {
|
||||
var patchedBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": "repo1",
|
||||
"identifier": "abc123",
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
patchedBody = decodeReqBody(t, r)
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "repo1",
|
||||
"public": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if patchedBody["private"] != false {
|
||||
t.Fatalf("public should set private=false, got %v", patchedBody["private"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_WithDescription(t *testing.T) {
|
||||
var patchedBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": "repo1",
|
||||
"identifier": "abc123",
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
patchedBody = decodeReqBody(t, r)
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "repo1",
|
||||
"description": "updated description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if patchedBody["description"] != "updated description" {
|
||||
t.Fatalf("description: got %q", patchedBody["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_FromCSV(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n")
|
||||
|
||||
var fetchedRepos []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json")
|
||||
fetchedRepos = append(fetchedRepos, name)
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": name,
|
||||
"identifier": "ident-" + name,
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{"from": csvPath})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fetchedRepos) != 2 {
|
||||
t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_PartialFailure(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && strings.Contains(r.URL.Path, "fail-repo"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.Method == "GET":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": "ok-repo",
|
||||
"identifier": "abc123",
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "ok-repo,fail-repo",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from partial failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to update") {
|
||||
t.Fatalf("error should mention failed count, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_PreservesIdentifier(t *testing.T) {
|
||||
var patchedBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
writeJSONResp(t, w, map[string]interface{}{
|
||||
"name": "my-repo",
|
||||
"identifier": "xyz-789",
|
||||
"description": "old desc",
|
||||
})
|
||||
case r.Method == "PATCH":
|
||||
patchedBody = decodeReqBody(t, r)
|
||||
writeJSONResp(t, w, map[string]interface{}{"id": float64(1)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBatchUpdateShortcut(t, server, map[string]string{
|
||||
"names": "my-repo",
|
||||
"description": "new desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if patchedBody["name"] != "my-repo" {
|
||||
t.Fatalf("name: got %q", patchedBody["name"])
|
||||
}
|
||||
if patchedBody["identifier"] != "xyz-789" {
|
||||
t.Fatalf("identifier: got %q", patchedBody["identifier"])
|
||||
}
|
||||
if patchedBody["description"] != "new desc" {
|
||||
t.Fatalf("description: got %q", patchedBody["description"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- readNamesFromCSV tests ----
|
||||
|
||||
func TestReadNamesFromCSV_Normal(t *testing.T) {
|
||||
path := writeTempCSV(t, "name\nrepo1\nrepo2\nrepo3\n")
|
||||
|
||||
names, err := readNamesFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(names) != 3 {
|
||||
t.Fatalf("got %d names, want 3", len(names))
|
||||
}
|
||||
if names[0] != "repo1" || names[1] != "repo2" || names[2] != "repo3" {
|
||||
t.Fatalf("got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_MissingNameColumn(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,description\nval1,desc1\nval2,desc2\n")
|
||||
|
||||
names, err := readNamesFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("got %d names, want 2 (falls back to col 0)", len(names))
|
||||
}
|
||||
if names[0] != "val1" || names[1] != "val2" {
|
||||
t.Fatalf("got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_OnlyHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "name\n")
|
||||
_, err := readNamesFromCSV(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_SkipsEmptyName(t *testing.T) {
|
||||
path := writeTempCSV(t, "name\nrepo1\n\nrepo2\n")
|
||||
|
||||
names, err := readNamesFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("got %d names, want 2", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_FileNotFound(t *testing.T) {
|
||||
_, err := readNamesFromCSV("/nonexistent/path.csv")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_CaseInsensitiveHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "NAME\nrepo1\nrepo2\n")
|
||||
|
||||
names, err := readNamesFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("got %d names, want 2", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNamesFromCSV_ExtraColumns(t *testing.T) {
|
||||
path := writeTempCSV(t, "name,extra,another\nrepo1,x,y\nrepo2,a,b\n")
|
||||
|
||||
names, err := readNamesFromCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("got %d names, want 2", len(names))
|
||||
}
|
||||
if names[0] != "repo1" || names[1] != "repo2" {
|
||||
t.Fatalf("got %v", names)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-update",
|
||||
Description: "Update settings for multiple repositories",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "description", Short: "d", Usage: "Shared description for all repos"},
|
||||
{Name: "private", Usage: "Set repos to private", Bool: true, Default: "false"},
|
||||
{Name: "public", Usage: "Set repos to public", Bool: true, Default: "false"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var repoNames []string
|
||||
if namesStr := ctx.Arg("names"); namesStr != "" {
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
repoNames = append(repoNames, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvNames, err := readNamesFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoNames = append(repoNames, csvNames...)
|
||||
}
|
||||
if len(repoNames) == 0 {
|
||||
return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv")
|
||||
}
|
||||
|
||||
setPrivate := ctx.Arg("private") == "true"
|
||||
setPublic := ctx.Arg("public") == "true"
|
||||
if setPrivate && setPublic {
|
||||
return fmt.Errorf("cannot use both --private and --public")
|
||||
}
|
||||
changeVisibility := setPrivate || setPublic
|
||||
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
desc := ctx.Arg("description")
|
||||
|
||||
summary := repoBatchSummary{
|
||||
Owner: ctx.Owner,
|
||||
Action: "update",
|
||||
DryRun: dryRun,
|
||||
Total: len(repoNames),
|
||||
Results: make([]repoBatchResult, 0, len(repoNames)),
|
||||
}
|
||||
|
||||
for _, name := range repoNames {
|
||||
result := repoBatchResult{Name: name}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch current repo info to get required fields for PATCH
|
||||
current, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s", ctx.Owner, name), nil)
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = fmt.Sprintf("fetch repo: %v", err)
|
||||
summary.Failed++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
curData, _ := current.Data.(map[string]interface{})
|
||||
curName, _ := curData["name"].(string)
|
||||
identifier, _ := curData["identifier"].(string)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": curName,
|
||||
"identifier": identifier,
|
||||
}
|
||||
if desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
if changeVisibility {
|
||||
body["private"] = setPrivate
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("/%s/%s", ctx.Owner, name), body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "updated"
|
||||
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 repo(s) failed to update", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readNamesFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 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, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
nameCol := -1
|
||||
for i, h := range header {
|
||||
if strings.ToLower(strings.TrimSpace(h)) == "name" {
|
||||
nameCol = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if nameCol == -1 {
|
||||
nameCol = 0
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, record := range records[1:] {
|
||||
if nameCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(record[nameCol])
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
|
@ -3,12 +3,16 @@ package repo
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
shortcuts := []*common.Shortcut{
|
||||
newBatchCreateShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
@ -33,7 +37,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取仓库列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -47,7 +51,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("查看仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -55,20 +59,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a new repository",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Create repository: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", `--name "my-project"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Get current user login for the create path
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current user: %w", err)
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ := userData["login"].(string)
|
||||
|
|
@ -89,7 +98,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -97,13 +106,20 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "fork",
|
||||
Description: "Fork a repository",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Fork repository %s/%s", ctx.Owner, ctx.Repo), nil
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Fork 仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -111,16 +127,145 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a repository",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("DELETE repository %s/%s", ctx.Owner, ctx.Repo), nil
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update repository settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
{Name: "private", Usage: "Set private (true/false)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if p := ctx.Arg("private"); p != "" {
|
||||
body["private"] = p == "true"
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --description, --private is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "members",
|
||||
Description: "List repository members (collaborators)",
|
||||
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()+"/collaborators", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite",
|
||||
Description: "Invite a member to a repository",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID := ctx.Arg("user-id")
|
||||
return fmt.Sprintf("Invite user %s to %s/%s", userID, ctx.Owner, ctx.Repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to invite user %d to %s/%s: %w", uid, ctx.Owner, ctx.Repo, err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-member",
|
||||
Description: "Remove a member from a repository",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID := ctx.Arg("user-id")
|
||||
return fmt.Sprintf("Remove user %s from %s/%s", userID, ctx.Owner, ctx.Repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove user %d from %s/%s: %w", uid, ctx.Owner, ctx.Repo, err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 合并批量成员管理命令
|
||||
shortcuts = append(shortcuts, BatchMemberShortcuts()...)
|
||||
|
||||
return shortcuts
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword", "--keyword my-project")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("搜索仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -38,12 +42,39 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword", "--keyword zhangsan")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("搜索用户失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "issues",
|
||||
Description: "Search issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||
{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
|
||||
}
|
||||
keyword, _ := ctx.RequireArg("keyword", "--keyword myproject")
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
package team
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List teams in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{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 {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
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", org), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a team in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Team name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Team description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
name, _ := ctx.RequireArg("name", `--name "My Team"`)
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "members",
|
||||
Description: "List members of a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
|
||||
{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 {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
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/%s/members", org, id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "member-add",
|
||||
Description: "Add a user to a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
{Name: "user", Short: "u", Usage: "User login or ID to add", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
team, _ := ctx.RequireArg("team", "--team dev-team")
|
||||
user, _ := ctx.RequireArg("user", "--user alice")
|
||||
body := map[string]interface{}{
|
||||
"user_id": user,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "member-remove",
|
||||
Description: "Remove a user from a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
{Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
team, _ := ctx.RequireArg("team", "--team dev-team")
|
||||
user, _ := ctx.RequireArg("user", "--user alice")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -26,13 +26,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
login, err := ctx.RequireArg("login", "--login zhangsan")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("查看用户信息失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 支持的Webhook事件类型
|
||||
var supportedEvents = []string{
|
||||
"push",
|
||||
"pull_request",
|
||||
"issue",
|
||||
"issue_assign",
|
||||
"issue_comment",
|
||||
"pull_request_assign",
|
||||
"pull_request_comment",
|
||||
"merge_request",
|
||||
"repository",
|
||||
"branch",
|
||||
"tag",
|
||||
}
|
||||
|
||||
func isEventSupported(event string) bool {
|
||||
for _, supported := range supportedEvents {
|
||||
if event == supported {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseEvents(eventsStr string) []string {
|
||||
if eventsStr == "" {
|
||||
return []string{"push"} // 默认事件
|
||||
}
|
||||
events := strings.Split(eventsStr, ",")
|
||||
var validEvents []string
|
||||
for _, event := range events {
|
||||
event = strings.TrimSpace(event)
|
||||
if isEventSupported(event) {
|
||||
validEvents = append(validEvents, event)
|
||||
}
|
||||
}
|
||||
return validEvents
|
||||
}
|
||||
|
||||
// webhookRepoPath returns the webhook API path prefix: /v1/{owner}/{repo}
|
||||
// Note: BaseURL already includes /api prefix
|
||||
func webhookRepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List all webhooks for a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
|
||||
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated), e.g., push,pull_request,issue", Default: "push"},
|
||||
{Name: "active", Usage: "Webhook active status (true/false)", Default: "true"},
|
||||
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
|
||||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events := parseEvents(ctx.Arg("events"))
|
||||
if len(events) == 0 {
|
||||
return clierrors.InputError(
|
||||
"no valid events specified",
|
||||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
"content_type": "json",
|
||||
}
|
||||
|
||||
if len(events) > 0 {
|
||||
payload["events"] = events
|
||||
} else {
|
||||
payload["events"] = []string{"push"}
|
||||
}
|
||||
|
||||
if secret := ctx.Arg("secret"); secret != "" {
|
||||
payload["secret"] = secret
|
||||
}
|
||||
|
||||
if description := ctx.Arg("description"); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Webhook 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an existing webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "url", Short: "u", Usage: "Webhook callback URL"},
|
||||
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated)"},
|
||||
{Name: "active", Usage: "Webhook active status (true/false)"},
|
||||
{Name: "content_type", Usage: "Content type (json/form)"},
|
||||
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
|
||||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
"content_type": "json",
|
||||
}
|
||||
|
||||
// 如果用户没有提供URL,获取当前webhook的URL
|
||||
webhookURL := ctx.Arg("url")
|
||||
if webhookURL == "" {
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Webhook 当前信息失败: %w", err)
|
||||
}
|
||||
webhookData, ok := getEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to parse webhook data")
|
||||
}
|
||||
currentURL, ok := webhookData["url"].(string)
|
||||
if !ok || currentURL == "" {
|
||||
return fmt.Errorf("failed to get current webhook URL")
|
||||
}
|
||||
webhookURL = currentURL
|
||||
}
|
||||
payload["url"] = webhookURL
|
||||
|
||||
if events := ctx.Arg("events"); events != "" {
|
||||
validEvents := parseEvents(events)
|
||||
if len(validEvents) == 0 {
|
||||
return clierrors.InputError(
|
||||
"no valid events specified",
|
||||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||||
)
|
||||
}
|
||||
payload["events"] = validEvents
|
||||
}
|
||||
|
||||
|
||||
if contentType := ctx.Arg("content_type"); contentType != "" {
|
||||
payload["content_type"] = contentType
|
||||
}
|
||||
|
||||
if secret := ctx.Arg("secret"); secret != "" {
|
||||
payload["secret"] = secret
|
||||
}
|
||||
|
||||
if description := ctx.Arg("description"); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
|
||||
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 Webhook 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if delErr != nil {
|
||||
// 验证是否真的删除成功(类似release的处理)
|
||||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if viewErr != nil {
|
||||
// Webhook不存在了,说明删除成功
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "Webhook deleted successfully",
|
||||
}, nil))
|
||||
}
|
||||
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "Webhook deleted successfully",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "test",
|
||||
Description: "Test a webhook delivery (send a ping event)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "event", Short: "e", Usage: "Event type to test (default: push)", Default: "push"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventType := ctx.Arg("event")
|
||||
if !isEventSupported(eventType) {
|
||||
return clierrors.InputError(
|
||||
fmt.Sprintf("unsupported event type: %s", eventType),
|
||||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("测试 Webhook 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show webhook details",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "events",
|
||||
Description: "List all supported event types for webhooks",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
eventInfo := make([]map[string]interface{}, 0)
|
||||
for _, event := range supportedEvents {
|
||||
eventInfo = append(eventInfo, map[string]interface{}{
|
||||
"event": event,
|
||||
"supported": true,
|
||||
"description": getEventDescription(event),
|
||||
})
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getEventDescription(event string) string {
|
||||
descriptions := map[string]string{
|
||||
"push": "Code push events",
|
||||
"pull_request": "Pull request events",
|
||||
"issue": "Issue events",
|
||||
"issue_assign": "Issue assignment events",
|
||||
"issue_comment": "Issue comment events",
|
||||
"pull_request_assign": "Pull request assignment events",
|
||||
"pull_request_comment":"Pull request comment events",
|
||||
"merge_request": "Merge request events",
|
||||
"repository": "Repository events",
|
||||
"branch": "Branch creation/deletion events",
|
||||
"tag": "Tag creation/deletion events",
|
||||
}
|
||||
if desc, ok := descriptions[event]; ok {
|
||||
return desc
|
||||
}
|
||||
return "Custom event"
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestIsEventSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "supported event - push",
|
||||
event: "push",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "supported event - pull_request",
|
||||
event: "pull_request",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "supported event - issue",
|
||||
event: "issue",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported event",
|
||||
event: "unsupported_event",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty event",
|
||||
event: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isEventSupported(tt.event)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isEventSupported(%q) = %v, want %v", tt.event, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
events string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "single event",
|
||||
events: "push",
|
||||
expected: []string{"push"},
|
||||
},
|
||||
{
|
||||
name: "multiple events",
|
||||
events: "push,pull_request,issue",
|
||||
expected: []string{"push", "pull_request", "issue"},
|
||||
},
|
||||
{
|
||||
name: "events with spaces",
|
||||
events: "push, pull_request, issue",
|
||||
expected: []string{"push", "pull_request", "issue"},
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid events",
|
||||
events: "push,invalid_event,pull_request",
|
||||
expected: []string{"push", "pull_request"},
|
||||
},
|
||||
{
|
||||
name: "empty string - default to push",
|
||||
events: "",
|
||||
expected: []string{"push"},
|
||||
},
|
||||
{
|
||||
name: "all invalid events",
|
||||
events: "invalid1,invalid2",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseEvents(tt.events)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("parseEvents(%q) returned %d events, want %d", tt.events, len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
for i, event := range result {
|
||||
if event != tt.expected[i] {
|
||||
t.Errorf("parseEvents(%q)[%d] = %q, want %q", tt.events, i, event, tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEventDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "push event description",
|
||||
event: "push",
|
||||
expected: "Code push events",
|
||||
},
|
||||
{
|
||||
name: "pull_request event description",
|
||||
event: "pull_request",
|
||||
expected: "Pull request events",
|
||||
},
|
||||
{
|
||||
name: "issue event description",
|
||||
event: "issue",
|
||||
expected: "Issue events",
|
||||
},
|
||||
{
|
||||
name: "unknown event description",
|
||||
event: "unknown_event",
|
||||
expected: "Custom event",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getEventDescription(tt.event)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getEventDescription(%q) = %q, want %q", tt.event, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
|
||||
if len(shortcuts) == 0 {
|
||||
t.Fatal("Shortcuts() returned empty slice")
|
||||
}
|
||||
|
||||
// 验证所有必需的shortcuts都存在
|
||||
expectedShortcuts := []string{
|
||||
"list", "create", "update", "delete", "test", "info", "events",
|
||||
}
|
||||
|
||||
shortcutNames := make(map[string]bool)
|
||||
for _, sc := range shortcuts {
|
||||
shortcutNames[sc.Name] = true
|
||||
}
|
||||
|
||||
for _, expected := range expectedShortcuts {
|
||||
if !shortcutNames[expected] {
|
||||
t.Errorf("Missing shortcut: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证每个shortcut的基本属性
|
||||
for _, sc := range shortcuts {
|
||||
if sc.Name == "" {
|
||||
t.Error("Shortcut has empty Name")
|
||||
}
|
||||
if sc.Description == "" {
|
||||
t.Errorf("Shortcut %q has empty Description", sc.Name)
|
||||
}
|
||||
if sc.Run == nil {
|
||||
t.Errorf("Shortcut %q has nil Run function", sc.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookEventsList tests the events shortcut to ensure it returns valid event information
|
||||
func TestWebhookEventsList(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
var eventsShortcut *common.Shortcut
|
||||
for _, sc := range shortcuts {
|
||||
if sc.Name == "events" {
|
||||
eventsShortcut = sc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if eventsShortcut == nil {
|
||||
t.Fatal("Events shortcut not found")
|
||||
}
|
||||
|
||||
// 验证所有支持的事件都有描述
|
||||
for _, event := range supportedEvents {
|
||||
desc := getEventDescription(event)
|
||||
if desc == "" {
|
||||
t.Errorf("Event %q has empty description", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventValidationIntegration tests event validation in an integrated manner
|
||||
func TestEventValidationIntegration(t *testing.T) {
|
||||
// 测试所有支持的事件都能被正确识别
|
||||
for _, event := range supportedEvents {
|
||||
if !isEventSupported(event) {
|
||||
t.Errorf("Supported event %q is not recognized by isEventSupported", event)
|
||||
}
|
||||
// 确保描述不为空
|
||||
desc := getEventDescription(event)
|
||||
if desc == "" {
|
||||
t.Errorf("Event %q has empty description", event)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试解析包含所有支持的事件字符串
|
||||
allEvents := strings.Join(supportedEvents, ",")
|
||||
parsed := parseEvents(allEvents)
|
||||
if len(parsed) != len(supportedEvents) {
|
||||
t.Errorf("Parsing all events returned %d results, expected %d", len(parsed), len(supportedEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParseEvents benchmarks the event parsing function
|
||||
func BenchmarkParseEvents(b *testing.B) {
|
||||
eventsStr := "push,pull_request,issue,issue_assign,issue_comment,pull_request_assign,pull_request_comment"
|
||||
for i := 0; i < b.N; i++ {
|
||||
parseEvents(eventsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIsEventSupported benchmarks the event validation function
|
||||
func BenchmarkIsEventSupported(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, event := range supportedEvents {
|
||||
isEventSupported(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# wiki 模块变更日志
|
||||
|
||||
## 2026-05-31 新增 `wiki +lint` 文档质量检查命令
|
||||
|
||||
> **注意:`+lint` 命令当前仅在本地编译版本中可用**(全局安装的 `gitlink-cli` 暂未包含)。需先 `go build -o gitlink-cli.exe .` 然后使用 `./gitlink-cli.exe wiki +lint`。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 在仓库目录下,owner/repo 自动从 git remote 解析
|
||||
gitlink-cli wiki +lint
|
||||
|
||||
# 只运行指定检查项(逗号分隔)
|
||||
gitlink-cli wiki +lint --check links
|
||||
gitlink-cli wiki +lint --check links,headings
|
||||
|
||||
# JSON 格式输出
|
||||
gitlink-cli wiki +lint --format json
|
||||
```
|
||||
|
||||
### 检查项
|
||||
|
||||
| 检查名称 | 级别 | 说明 |
|
||||
|----------|------|------|
|
||||
| empty | error | 页面内容为空 |
|
||||
| headings | warning | 缺少 H1 标题(不以 `# ` 开头) |
|
||||
| short | warning | 内容不足 50 字符 |
|
||||
| links | error | wiki 内链指向不存在的页面 |
|
||||
| images | warning | 外部图片 URL 返回 404 或不可达 |
|
||||
|
||||
### 主要修改
|
||||
|
||||
**文件**: `shortcuts/wiki/wiki.go`
|
||||
|
||||
#### 1. 新增类型
|
||||
|
||||
```go
|
||||
type LintIssue struct {
|
||||
Page string `json:"page"`
|
||||
Level string `json:"level"` // "error" / "warning"
|
||||
Check string `json:"check"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LintSummary struct {
|
||||
Repository string `json:"repository"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalIssues int `json:"total_issues"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Results []LintIssue `json:"results"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Bug 修复
|
||||
|
||||
- 修复 lint 获取页面内容时使用 `title` 而非 `sub_url` 导致部分页面 500 错误的问题
|
||||
|
||||
#### 3. 新增函数(含 sub_url/title 双字段支持)
|
||||
|
||||
- `isCheckEnabled(checkFilter, name string) bool` — 判断指定检查是否被 `--check` 参数选中
|
||||
- `checkEmpty(page, content string) []LintIssue` — 检测空页面
|
||||
- `checkHeading(page, content string) []LintIssue` — 检测缺少 H1
|
||||
- `checkShort(page, content string) []LintIssue` — 检测内容过短
|
||||
- `checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue` — 正则提取内链,在已知标题集合中查找死链
|
||||
- `checkImages(page, content string, httpClient *http.Client) []LintIssue` — 正则提取外部图片 URL,HEAD 请求检查可达性(超时 5s)
|
||||
- `runLint(ctx *common.RuntimeContext) error` — lint 主流程:解析 owner/repo → 获取页面列表 → 逐页检查 → 汇总输出
|
||||
|
||||
#### 4. 新增 Shortcut 注册
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "lint",
|
||||
Description: "Check wiki pages for quality issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
|
||||
},
|
||||
Run: runLint,
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 新增 import
|
||||
|
||||
`net/http`, `regexp`, `strings`, `time`
|
||||
|
|
@ -0,0 +1,826 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var projectIDCache sync.Map
|
||||
|
||||
func wikiPath(endpoint string) string {
|
||||
return "/wiki/open/" + endpoint
|
||||
}
|
||||
|
||||
// getGatewayClient returns a client targeting the Wiki API gateway.
|
||||
// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn
|
||||
// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default.
|
||||
// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil.
|
||||
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
|
||||
baseURL := ctx.GatewayBaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://gateway.gitlink.org.cn/api"
|
||||
}
|
||||
httpClient := ctx.GatewayHTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = auth.NewHTTPClient()
|
||||
}
|
||||
return &client.Client{
|
||||
HTTP: httpClient,
|
||||
BaseURL: baseURL,
|
||||
SkipJSONSuffix: true,
|
||||
Debug: ctx.Client.Debug,
|
||||
}
|
||||
}
|
||||
|
||||
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
|
||||
gc := getGatewayClient(ctx)
|
||||
env, err := gc.Do(method, path, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapGatewayResponse(env)
|
||||
}
|
||||
|
||||
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
|
||||
gc := getGatewayClient(ctx)
|
||||
env, err := gc.Do(method, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapGatewayResponse(env)
|
||||
}
|
||||
|
||||
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
|
||||
resp, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return env, nil
|
||||
}
|
||||
if code, ok := resp["code"]; ok {
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
// HTTP 2xx 全部视为成功(200 OK / 201 Created / 202 Accepted / 204 No Content 等)
|
||||
// 之前只接受 200/201,导致 DELETE 返回 204 时被误判为失败
|
||||
if v < 200 || v >= 300 {
|
||||
msg, _ := resp["msg"].(string)
|
||||
// 必须返回 *clierrors.CLIError,否则 shortcuts/common.TryPrintError
|
||||
// 的 errors.As 无法识别,错误就不会按 envelope 格式输出,
|
||||
// 会回退到 stderr 的纯文本输出(破坏 --format json/table/yaml)。
|
||||
kind := clierrors.KindServer
|
||||
if int(v) == 404 {
|
||||
kind = clierrors.KindNotFound
|
||||
} else if int(v) == 401 || int(v) == 403 {
|
||||
kind = clierrors.KindForbidden
|
||||
}
|
||||
return nil, clierrors.New(kind, msg,
|
||||
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
|
||||
}
|
||||
}
|
||||
}
|
||||
if innerData, ok := resp["data"]; ok {
|
||||
return output.SuccessEnvelope(innerData, env.Meta), nil
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
if cached, ok := projectIDCache.Load(key); ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected response from project detail API")
|
||||
}
|
||||
|
||||
pid, ok := data["project_id"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("project_id not found in project detail response")
|
||||
}
|
||||
|
||||
var pidStr string
|
||||
switch v := pid.(type) {
|
||||
case float64:
|
||||
pidStr = fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
pidStr = fmt.Sprintf("%d", v)
|
||||
default:
|
||||
pidStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
projectIDCache.Store(key, pidStr)
|
||||
return pidStr, nil
|
||||
}
|
||||
|
||||
func parseProjectIDInt(pid string) int {
|
||||
n, _ := strconv.Atoi(pid)
|
||||
return n
|
||||
}
|
||||
|
||||
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
if filePath != "" {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("no content provided")
|
||||
}
|
||||
|
||||
// fetchPageContent 获取 wiki 页面明文内容。
|
||||
//
|
||||
// 自动重试策略:GitLink 后端创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
|
||||
// 而 wiki +list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且
|
||||
// pageName 不带 ".-" 后缀,自动用 pageName+".-" 重试一次。
|
||||
//
|
||||
// 返回值:
|
||||
// - content: 解码后的明文 markdown
|
||||
// - actualPageName: 实际查询成功的 pageName(可能带 ".-" 后缀),供调用方做后续写操作
|
||||
// - err: 错误信息
|
||||
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
|
||||
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
|
||||
if err == nil {
|
||||
return c, actual, nil
|
||||
}
|
||||
// 首次失败且 pageName 不带 ".-" 后缀:自动重试一次
|
||||
if !strings.HasSuffix(pageName, ".-") {
|
||||
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
|
||||
if err2 == nil {
|
||||
return c2, actual2, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
|
||||
}
|
||||
|
||||
// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)。
|
||||
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
q.Set("pageName", pageName)
|
||||
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unexpected response from getWiki")
|
||||
}
|
||||
b64, _ := data["content_base64"].(string)
|
||||
if b64 == "" {
|
||||
return "", pageName, nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode page content: %w", err)
|
||||
}
|
||||
return string(decoded), pageName, nil
|
||||
}
|
||||
|
||||
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。
|
||||
// 调用方负责处理错误(包括 404)和后续的 sub_url 重试逻辑。
|
||||
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
q.Set("pageName", pageName)
|
||||
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
|
||||
}
|
||||
|
||||
func resolveContent(ctx *common.RuntimeContext) (string, error) {
|
||||
if content := ctx.Arg("content"); content != "" {
|
||||
return content, nil
|
||||
}
|
||||
if filePath := ctx.Arg("file"); filePath != "" {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
|
||||
}
|
||||
|
||||
func cleanWikiList(env *output.Envelope) {
|
||||
items, ok := env.Data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(m, "wiki_clone_link")
|
||||
if raw, ok := m["sub_url"].(string); ok {
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
m["sub_url"] = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码。
|
||||
//
|
||||
// 设计权衡(agent 友好性):
|
||||
// - 后端返回的字段(content_base64、sidebar、footer)都是 base64 编码,对 agent 不可读
|
||||
// - 解码后用明文替换/移除原始字段,agent 可直接阅读、抽取、总结
|
||||
// - 节省 ~33% token(base64 编码膨胀部分)
|
||||
//
|
||||
// 已知字段映射:
|
||||
// - content_base64 → content(重命名,删除原字段)
|
||||
// - sidebar → sidebar(原地替换,仅当解码成功)
|
||||
// - footer → footer(原地替换,仅当解码成功)
|
||||
//
|
||||
// 安全策略:仅当 base64.StdEncoding.DecodeString 成功时才替换;
|
||||
// 若后端某天改为明文,解码失败会自动跳过,不影响兼容性。
|
||||
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
|
||||
data := env.Data
|
||||
|
||||
// 后端某些端点(如 createWiki/updateWiki)把 data 返回为 JSON 字符串,
|
||||
// client.go 会把它解析为 json.RawMessage(而非 map);这里先转回 map 再处理。
|
||||
// view/getWiki 端点直接返回 JSON 对象,data 已是 map[string]interface{}。
|
||||
if raw, ok := data.(json.RawMessage); ok {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
data = m
|
||||
env.Data = m
|
||||
}
|
||||
}
|
||||
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
// content_base64 → content(重命名)
|
||||
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
|
||||
m["content"] = string(decoded)
|
||||
delete(m, "content_base64")
|
||||
}
|
||||
}
|
||||
// sidebar / footer:原地替换(仅当能解码为 base64 时)
|
||||
for _, field := range []string{"sidebar", "footer"} {
|
||||
if b64, ok := m[field].(string); ok && b64 != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
|
||||
m[field] = string(decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// --- lint types and implementation ---
|
||||
|
||||
type LintIssue struct {
|
||||
Page string `json:"page"`
|
||||
Level string `json:"level"`
|
||||
Check string `json:"check"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LintSummary struct {
|
||||
Repository string `json:"repository"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalIssues int `json:"total_issues"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Results []LintIssue `json:"results"`
|
||||
}
|
||||
|
||||
var (
|
||||
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
|
||||
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`)
|
||||
)
|
||||
|
||||
func isCheckEnabled(checkFilter, name string) bool {
|
||||
if checkFilter == "" {
|
||||
return true
|
||||
}
|
||||
for _, c := range strings.Split(checkFilter, ",") {
|
||||
if strings.TrimSpace(c) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkEmpty(page, content string) []LintIssue {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkHeading(page, content string) []LintIssue {
|
||||
if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") {
|
||||
return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkShort(page, content string) []LintIssue {
|
||||
if content != "" && len(content) < 50 {
|
||||
return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue {
|
||||
var issues []LintIssue
|
||||
for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
target := m[2]
|
||||
// skip external links and anchors
|
||||
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") {
|
||||
continue
|
||||
}
|
||||
decoded, _ := url.PathUnescape(target)
|
||||
if decoded == "" {
|
||||
decoded = target
|
||||
}
|
||||
if !knownTitles[decoded] && !knownTitles[target] {
|
||||
issues = append(issues, LintIssue{
|
||||
Page: page,
|
||||
Level: "error",
|
||||
Check: "links",
|
||||
Message: fmt.Sprintf("dead link: [%s](%s) -> page %q not found", m[1], target, decoded),
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func checkImages(page, content string, httpClient *http.Client) []LintIssue {
|
||||
var issues []LintIssue
|
||||
for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
imgURL := m[2]
|
||||
req, err := http.NewRequest("HEAD", imgURL, nil)
|
||||
if err != nil {
|
||||
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)})
|
||||
continue
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)})
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func runLint(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkFilter := ctx.Arg("check")
|
||||
fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo)
|
||||
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch page list
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list wiki pages: %w", err)
|
||||
}
|
||||
|
||||
// Parse pages: title for display/link-check, sub_url for fetching
|
||||
type pageInfo struct {
|
||||
title string
|
||||
subURL string
|
||||
}
|
||||
knownTitles := make(map[string]bool)
|
||||
var pages []pageInfo
|
||||
if items, ok := env.Data.([]interface{}); ok {
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
title, _ := m["title"].(string)
|
||||
subURL, _ := m["sub_url"].(string)
|
||||
if title != "" {
|
||||
knownTitles[title] = true
|
||||
if subURL == "" {
|
||||
subURL = title
|
||||
}
|
||||
pages = append(pages, pageInfo{title: title, subURL: subURL})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No wiki pages found.")
|
||||
return ctx.OutputData(LintSummary{
|
||||
Repository: ctx.Owner + "/" + ctx.Repo,
|
||||
Results: []LintIssue{},
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP client for image checks with 5s timeout
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
var allIssues []LintIssue
|
||||
|
||||
for _, p := range pages {
|
||||
// Skip system pages (e.g. _Sidebar, _Footer, _Header)
|
||||
if strings.HasPrefix(p.title, "_") {
|
||||
continue
|
||||
}
|
||||
content, _, err := fetchPageContent(ctx, projectID, p.subURL)
|
||||
if err != nil {
|
||||
allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)})
|
||||
continue
|
||||
}
|
||||
|
||||
if isCheckEnabled(checkFilter, "empty") {
|
||||
allIssues = append(allIssues, checkEmpty(p.title, content)...)
|
||||
}
|
||||
if isCheckEnabled(checkFilter, "headings") {
|
||||
allIssues = append(allIssues, checkHeading(p.title, content)...)
|
||||
}
|
||||
if isCheckEnabled(checkFilter, "short") {
|
||||
allIssues = append(allIssues, checkShort(p.title, content)...)
|
||||
}
|
||||
if isCheckEnabled(checkFilter, "links") {
|
||||
allIssues = append(allIssues, checkDeadLinks(p.title, content, knownTitles)...)
|
||||
}
|
||||
if isCheckEnabled(checkFilter, "images") {
|
||||
allIssues = append(allIssues, checkImages(p.title, content, httpClient)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Count errors/warnings
|
||||
var errCount, warnCount int
|
||||
for _, issue := range allIssues {
|
||||
if issue.Level == "error" {
|
||||
errCount++
|
||||
} else {
|
||||
warnCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Print issues to stderr
|
||||
for _, issue := range allIssues {
|
||||
if issue.Level == "error" {
|
||||
fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " ⚠ %s - %s\n", issue.Page, issue.Message)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount)
|
||||
|
||||
return ctx.OutputData(LintSummary{
|
||||
Repository: ctx.Owner + "/" + ctx.Repo,
|
||||
TotalPages: len(pages),
|
||||
TotalIssues: len(allIssues),
|
||||
Errors: errCount,
|
||||
Warnings: warnCount,
|
||||
Results: allIssues,
|
||||
})
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List all wiki pages",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
|
||||
if err != nil {
|
||||
// wiki +list 的 404 几乎总是意味着仓库未在 GitLink 网页端开启 Wiki 功能。
|
||||
// 这种情况下不要再提示"用 +list 查看"(循环引用),改为引导用户去开启 Wiki。
|
||||
var cliErr *clierrors.CLIError
|
||||
if errors.As(err, &cliErr) && cliErr.Kind == clierrors.KindNotFound {
|
||||
return clierrors.New(clierrors.KindNotFound,
|
||||
fmt.Sprintf("仓库 %s/%s 没有 Wiki 页面", ctx.Owner, ctx.Repo),
|
||||
fmt.Sprintf("请前往 GitLink 网页端 → 仓库 %s/%s → 设置 → 开启 Wiki 功能,开启后再创建页面", ctx.Owner, ctx.Repo))
|
||||
}
|
||||
return fmt.Errorf("获取 Wiki 页面列表失败: %w", err)
|
||||
}
|
||||
cleanWikiList(env)
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Page title", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title", `--title "Home Page"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := fetchWikiPage(ctx, projectID, title)
|
||||
if err != nil {
|
||||
// GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
|
||||
// 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败,
|
||||
// 自动用 title + ".-" 重试一次。
|
||||
if !strings.HasSuffix(title, ".-") {
|
||||
env, err = fetchWikiPage(ctx, projectID, title+".-")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// 用 CLIError 包装,保留底层错误类型,让 TryPrintError 能按 envelope 输出。
|
||||
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki;(2) 页面名拼错。
|
||||
return clierrors.Wrap(clierrors.KindNotFound,
|
||||
fmt.Sprintf("Wiki 页面 %q 不存在", title),
|
||||
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
|
||||
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo),
|
||||
err)
|
||||
}
|
||||
return outputWithDecodedContent(ctx, env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a wiki page",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
return fmt.Sprintf("Create wiki page: %s", title), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Page title", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "Wiki page content (plain text, will be base64-encoded)"},
|
||||
{Name: "file", Short: "f", Usage: "Read content from file"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title", `--title "Home Page"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": parseProjectIDInt(projectID),
|
||||
"pageName": title,
|
||||
"title": title,
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
}
|
||||
if msg := ctx.Arg("message"); msg != "" {
|
||||
body["message"] = msg
|
||||
}
|
||||
|
||||
env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Wiki 页面失败: %w", err)
|
||||
}
|
||||
return outputWithDecodedContent(ctx, env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a wiki page",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
return fmt.Sprintf("Update wiki page: %s", title), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Current page title to find (defaults to --title)"},
|
||||
{Name: "title", Short: "t", Usage: "New page title", Required: true},
|
||||
{Name: "cover", Short: "c", Usage: "Replace entire page content with this text"},
|
||||
{Name: "add", Short: "a", Usage: "Append text to existing page content"},
|
||||
{Name: "file", Short: "f", Usage: "Read content from file (used with --cover or --add)"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title", `--title "Home Page"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pageName := ctx.Arg("page")
|
||||
if pageName == "" {
|
||||
pageName = title
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
coverText := ctx.Arg("cover")
|
||||
addText := ctx.Arg("add")
|
||||
filePath := ctx.Arg("file")
|
||||
message := ctx.Arg("message")
|
||||
|
||||
var finalContent string
|
||||
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
|
||||
// --cover or --file alone: overwrite
|
||||
content, err := resolveUpdateContent(ctx, coverText, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finalContent = content
|
||||
} else if addText != "" {
|
||||
// --add: append to existing content
|
||||
newPart, err := resolveUpdateContent(ctx, addText, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, actualPageName, err := fetchPageContent(ctx, projectID, pageName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch existing page content for append: %w", err)
|
||||
}
|
||||
// fetchPageContent 可能因 GitLink 后端 ".-" 命名规则触发自动重试,
|
||||
// 用实际成功的 pageName(可能带 .- 后缀)作为 PUT 目标,否则后端会再次 404。
|
||||
pageName = actualPageName
|
||||
finalContent = existing + newPart
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": parseProjectIDInt(projectID),
|
||||
"pageName": url.QueryEscape(pageName),
|
||||
"title": title,
|
||||
"message": message,
|
||||
}
|
||||
if finalContent != "" {
|
||||
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(finalContent))
|
||||
}
|
||||
|
||||
env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 Wiki 页面失败: %w", err)
|
||||
}
|
||||
return outputWithDecodedContent(ctx, env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a wiki page",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
return fmt.Sprintf("Delete wiki page: %s", title), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Page title", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title", `--title "Home Page"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 解析实际 pageName(fetchPageContent 内部自动重试 ".-" 后缀)
|
||||
// 不做这步直接用 title DELETE,会导致后端"接受了请求但没真删"
|
||||
// (后端期望的 pageName 是 "X.-" 而非 "X")
|
||||
_, actualPageName, err := fetchPageContent(ctx, projectID, title)
|
||||
if err != nil {
|
||||
// 页面查不到 — 后端 getWiki API 返回 404。
|
||||
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki;(2) 页面名拼错。
|
||||
// 注意:GitLink 网页端对任意 ?wiki=xxx 都会渲染 SPA 壳子,
|
||||
// 不代表页面真实存在;以 wiki +list 的结果为准。
|
||||
return clierrors.New(clierrors.KindNotFound,
|
||||
fmt.Sprintf("Wiki 页面 %q 不存在", title),
|
||||
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
|
||||
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo))
|
||||
}
|
||||
|
||||
// 2. 用实际 pageName 调用 DELETE
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": parseProjectIDInt(projectID),
|
||||
"pageName": actualPageName,
|
||||
"message": "",
|
||||
}
|
||||
if _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body); delErr != nil {
|
||||
return fmt.Errorf("删除 Wiki 页面失败: %w", delErr)
|
||||
}
|
||||
|
||||
// 3. 删除后强制验证(GitLink deleteWiki 端点不可靠:即使返回 200,
|
||||
// 页面有时仍然存在)。GET 一次确认页面真的没了。
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
q.Set("pageName", actualPageName)
|
||||
if _, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q); viewErr == nil {
|
||||
// GET 还能查到 — 后端接受了 DELETE 但没真删
|
||||
return fmt.Errorf("删除 Wiki 页面失败:后端接受了请求但页面仍存在 (pageName=%s)", actualPageName)
|
||||
}
|
||||
// 删除成功 — 返回结构化数据,让用户/agent 能自助验证。
|
||||
// 说明三种用户常见的"以为没删干净"的现象:
|
||||
// 1) 网页端 ?wiki=xxx 仍可访问 → GitLink SPA 占位符(任何参数都渲染壳子)
|
||||
// 2) git log 仍能看到删除 commit → git 设计就是保留历史
|
||||
// 3) edit 跳转到 wiki=undefined → 网页端前端未正确处理 404
|
||||
// 这些都不是 CLI 删除不彻底,是 GitLink 网页端的 UX 问题。
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "Wiki page deleted successfully",
|
||||
"deleted_title": title,
|
||||
"actual_page_name": actualPageName,
|
||||
"wiki_repo": fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", ctx.Owner, ctx.Repo),
|
||||
"notes": []string{
|
||||
"页面已从 wiki 仓库 HEAD 彻底删除(git 工作树无残留)",
|
||||
"网页端 ?wiki=xxx URL 仍可访问是 SPA 占位符,不代表页面存在",
|
||||
"git 历史 commits 仍保留删除记录(git 的正常行为,非残留)",
|
||||
},
|
||||
"verify_commands": []string{
|
||||
fmt.Sprintf("gitlink-cli wiki +list --owner %s --repo %s", ctx.Owner, ctx.Repo),
|
||||
fmt.Sprintf("git clone https://gitlink.org.cn/%s/%s.wiki.git /tmp/wiki-check && git -C /tmp/wiki-check ls-files", ctx.Owner, ctx.Repo),
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lint",
|
||||
Description: "Check wiki pages for quality issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
|
||||
},
|
||||
Run: runLint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,663 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func resetProjectIDCache() {
|
||||
projectIDCache = sync.Map{}
|
||||
}
|
||||
|
||||
func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("writeJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- unwrapGatewayResponse tests ----
|
||||
|
||||
func TestUnwrapGatewayResponse_Success(t *testing.T) {
|
||||
env := output.SuccessEnvelope(map[string]interface{}{
|
||||
"code": float64(200),
|
||||
"data": map[string]interface{}{"id": float64(1)},
|
||||
}, nil)
|
||||
|
||||
result, err := unwrapGatewayResponse(env)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data, ok := result.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected map data")
|
||||
}
|
||||
if data["id"] != float64(1) {
|
||||
t.Fatalf("got %v, want 1", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapGatewayResponse_Code201(t *testing.T) {
|
||||
env := output.SuccessEnvelope(map[string]interface{}{
|
||||
"code": float64(201),
|
||||
"data": "ok",
|
||||
}, nil)
|
||||
|
||||
result, err := unwrapGatewayResponse(env)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Data != "ok" {
|
||||
t.Fatalf("got %v, want ok", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapGatewayResponse_BusinessError(t *testing.T) {
|
||||
env := output.SuccessEnvelope(map[string]interface{}{
|
||||
"code": float64(500),
|
||||
"msg": "内部错误",
|
||||
}, nil)
|
||||
|
||||
_, err := unwrapGatewayResponse(env)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// 必须是 *clierrors.CLIError,否则 TryPrintError 无法按 envelope 输出
|
||||
var cliErr *clierrors.CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
|
||||
}
|
||||
if cliErr.Kind != clierrors.KindServer {
|
||||
t.Errorf("Kind = %q, want %q", cliErr.Kind, clierrors.KindServer)
|
||||
}
|
||||
if cliErr.Message != "内部错误" {
|
||||
t.Errorf("Message = %q, want %q", cliErr.Message, "内部错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapGatewayResponse_NoDataField(t *testing.T) {
|
||||
env := output.SuccessEnvelope(map[string]interface{}{
|
||||
"code": float64(200),
|
||||
}, nil)
|
||||
|
||||
result, err := unwrapGatewayResponse(env)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Should return original envelope since no "data" field to extract
|
||||
if result != env {
|
||||
t.Fatal("expected original envelope when no data field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapGatewayResponse_NonMapData(t *testing.T) {
|
||||
env := output.SuccessEnvelope("plain text", nil)
|
||||
|
||||
result, err := unwrapGatewayResponse(env)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Data != "plain text" {
|
||||
t.Fatalf("got %v, want plain text", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolveProjectID tests ----
|
||||
|
||||
func TestResolveProjectID_Success(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/owner1/repo1/detail.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"project_id": float64(123)})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner1",
|
||||
Repo: "repo1",
|
||||
}
|
||||
pid, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pid != "123" {
|
||||
t.Fatalf("got %q, want %q", pid, "123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectID_Float64(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/owner2/repo2/detail.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"project_id": float64(456)})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner2",
|
||||
Repo: "repo2",
|
||||
}
|
||||
pid, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pid != "456" {
|
||||
t.Fatalf("got %q, want %q", pid, "456")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectID_CacheHit(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
callCount := 0
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.URL.Path == "/owner3/repo3/detail.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"project_id": float64(789)})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner3",
|
||||
Repo: "repo3",
|
||||
}
|
||||
|
||||
pid1, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
if pid1 != "789" {
|
||||
t.Fatalf("first call: got %q, want %q", pid1, "789")
|
||||
}
|
||||
|
||||
pid2, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if pid2 != "789" {
|
||||
t.Fatalf("second call: got %q, want %q", pid2, "789")
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Fatalf("API called %d times, want 1 (cache miss)", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectID_MissingProjectID(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{"name": "no-project-id"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner4",
|
||||
Repo: "repo4",
|
||||
}
|
||||
_, err := resolveProjectID(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectID_APIError(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner5",
|
||||
Repo: "repo5",
|
||||
}
|
||||
_, err := resolveProjectID(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- callWikiAPI HTTP request path tests ----
|
||||
|
||||
func TestCallWikiAPI_Success(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
var receivedPath, receivedMethod string
|
||||
var receivedBody []byte
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
receivedMethod = r.Method
|
||||
if r.Body != nil {
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(buf)
|
||||
receivedBody = buf[:n]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
GatewayBaseURL: server.URL,
|
||||
GatewayHTTPClient: server.Client(),
|
||||
}
|
||||
|
||||
env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if receivedMethod != "POST" {
|
||||
t.Errorf("method = %q, want POST", receivedMethod)
|
||||
}
|
||||
if receivedPath != "/wiki/open/test" {
|
||||
t.Errorf("path = %q, want /wiki/open/test", receivedPath)
|
||||
}
|
||||
if !strings.Contains(string(receivedBody), `"foo"`) {
|
||||
t.Errorf("body should contain foo: %s", string(receivedBody))
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map data, got %T", env.Data)
|
||||
}
|
||||
if data["id"] != float64(42) {
|
||||
t.Errorf("data[id] = %v, want 42", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWikiAPI_BusinessError(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
GatewayBaseURL: server.URL,
|
||||
GatewayHTTPClient: server.Client(),
|
||||
}
|
||||
_, err := callWikiAPI(ctx, "GET", "/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// 必须是 *clierrors.CLIError,code=400 对应 KindForbidden(401/403)或 KindServer
|
||||
// (其他非 200/404 错误),这里 400 走 KindServer 分支
|
||||
var cliErr *clierrors.CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
|
||||
}
|
||||
if cliErr.Message != "bad request" {
|
||||
t.Errorf("Message = %q, want %q", cliErr.Message, "bad request")
|
||||
}
|
||||
if cliErr.Suggestion == "" {
|
||||
t.Errorf("Suggestion should not be empty (helps user recover)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWikiAPI_GatewayHTTPError(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte(`upstream error`))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
GatewayBaseURL: server.URL,
|
||||
GatewayHTTPClient: server.Client(),
|
||||
}
|
||||
_, err := callWikiAPI(ctx, "GET", "/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 502")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) {
|
||||
// Verifies the SkipJSONSuffix path is correctly taken for gateway:
|
||||
// the URL should NOT have a .json appended.
|
||||
resetProjectIDCache()
|
||||
var receivedPath string
|
||||
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
w.Write([]byte(`{"code":200,"data":{}}`))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
GatewayBaseURL: server.URL,
|
||||
GatewayHTTPClient: server.Client(),
|
||||
}
|
||||
_, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if receivedPath != "/wiki/open/list" {
|
||||
t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath)
|
||||
}
|
||||
if strings.HasSuffix(receivedPath, ".json") {
|
||||
t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallWikiAPI_ConnectionRefused(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
// Use an unbound port to simulate connection failure
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"},
|
||||
GatewayBaseURL: "http://127.0.0.1:1",
|
||||
GatewayHTTPClient: &http.Client{},
|
||||
}
|
||||
_, err := callWikiAPI(ctx, "GET", "/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected connection error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- runLint / check rules tests ----
|
||||
|
||||
func TestIsCheckEnabled_Empty(t *testing.T) {
|
||||
if !isCheckEnabled("", "any") {
|
||||
t.Error("empty filter should enable all checks")
|
||||
}
|
||||
if !isCheckEnabled("empty,headings", "empty") {
|
||||
t.Error("should enable 'empty' in filter list")
|
||||
}
|
||||
if isCheckEnabled("headings", "empty") {
|
||||
t.Error("should not enable 'empty' when not in filter list")
|
||||
}
|
||||
if !isCheckEnabled(" empty , headings ", "empty") {
|
||||
t.Error("should trim whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEmpty(t *testing.T) {
|
||||
issues := checkEmpty("p1", "")
|
||||
if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" {
|
||||
t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues)
|
||||
}
|
||||
if issues := checkEmpty("p1", "some content"); issues != nil {
|
||||
t.Errorf("non-empty content should not produce issues, got %+v", issues)
|
||||
}
|
||||
if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 {
|
||||
t.Errorf("whitespace-only content should be empty, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHeading(t *testing.T) {
|
||||
// missing H1
|
||||
if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 {
|
||||
t.Errorf("expected 1 missing-heading issue, got %+v", issues)
|
||||
}
|
||||
// has H1
|
||||
if issues := checkHeading("p1", "# Title\nbody"); issues != nil {
|
||||
t.Errorf("H1 should not produce issues, got %+v", issues)
|
||||
}
|
||||
// empty content skipped
|
||||
if issues := checkHeading("p1", ""); issues != nil {
|
||||
t.Errorf("empty content should be skipped, got %+v", issues)
|
||||
}
|
||||
// whitespace prefix
|
||||
if issues := checkHeading("p1", " \n# Real Title"); issues != nil {
|
||||
t.Errorf("H1 after whitespace should not produce issues, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckShort(t *testing.T) {
|
||||
if issues := checkShort("p1", ""); issues != nil {
|
||||
t.Errorf("empty content should be skipped, got %+v", issues)
|
||||
}
|
||||
if issues := checkShort("p1", "short"); len(issues) != 1 {
|
||||
t.Errorf("expected 1 short issue, got %+v", issues)
|
||||
}
|
||||
long := strings.Repeat("a", 100)
|
||||
if issues := checkShort("p1", long); issues != nil {
|
||||
t.Errorf("long content should not produce issues, got %+v", issues)
|
||||
}
|
||||
// exactly 49 chars triggers
|
||||
if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 {
|
||||
t.Errorf("49-char content should be 'short', got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeadLinks(t *testing.T) {
|
||||
known := map[string]bool{"Home": true, "Guide": true}
|
||||
|
||||
// All known: no issues
|
||||
if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil {
|
||||
t.Errorf("all-known should not produce issues, got %+v", issues)
|
||||
}
|
||||
// Unknown link
|
||||
issues := checkDeadLinks("p1", "[Unknown](Unknown)", known)
|
||||
if len(issues) != 1 || issues[0].Check != "links" {
|
||||
t.Errorf("expected 1 dead link issue, got %+v", issues)
|
||||
}
|
||||
// External links skipped
|
||||
if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil {
|
||||
t.Errorf("external links should be skipped, got %+v", issues)
|
||||
}
|
||||
// Anchor links skipped
|
||||
if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil {
|
||||
t.Errorf("anchor links should be skipped, got %+v", issues)
|
||||
}
|
||||
// Mixed
|
||||
issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known)
|
||||
if len(issues) != 1 {
|
||||
t.Errorf("expected 1 dead link in mixed, got %+v", issues)
|
||||
}
|
||||
// Empty content
|
||||
if issues := checkDeadLinks("p1", "", known); issues != nil {
|
||||
t.Errorf("empty content should not produce issues, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckImages(t *testing.T) {
|
||||
// Mock image server
|
||||
imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "HEAD" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer imgServer.Close()
|
||||
|
||||
brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
defer brokenServer.Close()
|
||||
|
||||
httpClient := imgServer.Client()
|
||||
// Valid image (200)
|
||||
if issues := checkImages("p1", "", httpClient); issues != nil {
|
||||
t.Errorf("200 image should not produce issues, got %+v", issues)
|
||||
}
|
||||
// Broken image (404)
|
||||
issues := checkImages("p1", "", httpClient)
|
||||
if len(issues) != 1 {
|
||||
t.Errorf("expected 1 broken image issue, got %+v", issues)
|
||||
}
|
||||
// No images
|
||||
if issues := checkImages("p1", "no images here", httpClient); issues != nil {
|
||||
t.Errorf("no images should not produce issues, got %+v", issues)
|
||||
}
|
||||
// Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse)
|
||||
if issues := checkImages("p1", "", httpClient); len(issues) != 1 {
|
||||
t.Errorf("expected 1 invalid-URL issue, got %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLint_Integration(t *testing.T) {
|
||||
resetProjectIDCache()
|
||||
|
||||
// Mock main API (project detail) and gateway (wiki list + get)
|
||||
mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/detail.json") {
|
||||
writeJSON(t, w, map[string]interface{}{"project_id": float64(999)})
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer mainServer.Close()
|
||||
|
||||
// Page content (base64 encoded)
|
||||
goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3)))
|
||||
|
||||
wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/wiki/open/wikiPages":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": []map[string]interface{}{
|
||||
{"title": "Good", "sub_url": "Good"},
|
||||
{"title": "Empty", "sub_url": "Empty"},
|
||||
{"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped
|
||||
},
|
||||
})
|
||||
case "/wiki/open/getWiki":
|
||||
pageName := r.URL.Query().Get("pageName")
|
||||
var content string
|
||||
if pageName == "Empty" {
|
||||
content = "" // empty page
|
||||
} else {
|
||||
content = goodContent
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{"content_base64": content},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected wiki path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer wikiServer.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL},
|
||||
Owner: "owner1",
|
||||
Repo: "repo1",
|
||||
Format: "json",
|
||||
GatewayBaseURL: wikiServer.URL,
|
||||
GatewayHTTPClient: wikiServer.Client(),
|
||||
}
|
||||
|
||||
if err := runLint(ctx); err != nil {
|
||||
t.Fatalf("runLint: %v", err)
|
||||
}
|
||||
// _Sidebar is skipped, so TotalPages=2 (Good, Empty)
|
||||
// Empty page produces 1 "empty" error
|
||||
// We can't directly inspect the output envelope, but if no error, the function ran end-to-end
|
||||
}
|
||||
|
||||
// ---- resolveContent tests ----
|
||||
|
||||
func TestResolveContent_FromArg(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{"content": "inline content"},
|
||||
}
|
||||
got, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "inline content" {
|
||||
t.Errorf("got %q, want %q", got, "inline content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContent_FromFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "wiki.md")
|
||||
want := "# Title\n\nBody content from file"
|
||||
if err := os.WriteFile(path, []byte(want), 0600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{"file": path},
|
||||
}
|
||||
got, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContent_ArgTakesPrecedence(t *testing.T) {
|
||||
// When both --content and --file are set, --content wins
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "wiki.md")
|
||||
if err := os.WriteFile(path, []byte("from file"), 0600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{
|
||||
"content": "from arg",
|
||||
"file": path,
|
||||
},
|
||||
}
|
||||
got, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "from arg" {
|
||||
t.Errorf("arg should take precedence; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContent_Missing(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{},
|
||||
}
|
||||
_, err := resolveContent(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when neither --content nor --file is provided")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("err = %q, want to mention 'required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContent_FileNotFound(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"},
|
||||
}
|
||||
_, err := resolveContent(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,22 @@ skills/
|
|||
│ ├── REFERENCE.md # Release API 参考
|
||||
│ └── examples/
|
||||
│ └── release-workflow.md # Release 工作流
|
||||
├── gitlink-changelog/ # Release Notes / Changelog 生成
|
||||
│ ├── SKILL.md # Changelog 操作指南
|
||||
│ ├── references/
|
||||
│ │ ├── collect-data.md # 收集变更数据
|
||||
│ │ ├── classify-rules.md # 变更分类规则
|
||||
│ │ └── generate-and-publish.md # 生成并发布
|
||||
│ └── examples/
|
||||
│ └── full-workflow.md # 完整生成示例
|
||||
├── gitlink-health/ # 项目健康度报告
|
||||
│ ├── SKILL.md # 健康度报告操作指南
|
||||
│ ├── references/
|
||||
│ │ ├── collect-data.md # 收集项目数据
|
||||
│ │ ├── health-metrics.md # 指标计算和评分规则
|
||||
│ │ └── generate-report.md # 报告生成和输出
|
||||
│ └── examples/
|
||||
│ └── full-workflow.md # 完整生成示例
|
||||
├── gitlink-search/ # 搜索功能
|
||||
│ ├── SKILL.md # 搜索操作指南
|
||||
│ └── examples/
|
||||
|
|
@ -106,10 +122,29 @@ skills/
|
|||
│ ├── SKILL.md # CI 操作指南
|
||||
│ └── examples/
|
||||
│ └── ci-workflow.md # CI 工作流
|
||||
├── gitlink-wiki/ # Wiki 管理
|
||||
│ └── SKILL.md # Wiki 操作指南
|
||||
├── gitlink-pm/ # 项目管理
|
||||
│ └── SKILL.md # PM 操作指南
|
||||
└── gitlink-workflow/ # AI 自动化工作流
|
||||
└── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
├── gitlink-workflow/ # AI 自动化工作流
|
||||
│ └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
├── gitlink-issue-triage/ # Issue 自动分类
|
||||
│ ├── SKILL.md # AI Agent 主入口
|
||||
│ ├── README.md # 使用说明
|
||||
│ ├── references/ # 分析算法 + 应用手册
|
||||
│ └── examples/ # 批量/单 Issue 工作流示例
|
||||
├── gitlink-webhook/ # Webhook 管理
|
||||
│ └── SKILL.md # Webhook 操作指南
|
||||
├── gitlink-compliance/ # 安全与合规
|
||||
│ └── SKILL.md # 许可证、敏感信息、PII 扫描
|
||||
├── gitlink-onboard/ # 新人引导
|
||||
│ └── SKILL.md # Good First Issue 识别与欢迎评论
|
||||
├── gitlink-team/ # 团队管理
|
||||
│ └── SKILL.md # 团队操作指南
|
||||
├── gitlink-contrib/ # 贡献报告
|
||||
│ └── SKILL.md # 贡献统计与报告
|
||||
└── gitlink-code-insight/ # 功能全景
|
||||
└── SKILL.md # 全部 Shortcuts 分类展示
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -126,6 +161,7 @@ skills/
|
|||
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
|
||||
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
|
||||
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
|
||||
| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 |
|
||||
|
||||
### 辅助 Skills
|
||||
|
||||
|
|
@ -135,8 +171,17 @@ skills/
|
|||
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
|
||||
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
|
||||
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
|
||||
| **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
|
||||
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
|
||||
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
|
||||
| **gitlink-changelog** | Release Notes / Changelog 生成 | 自动收集 commits/PR/Issue,生成结构化版本说明 |
|
||||
| **gitlink-issue-triage** | Issue 自动分类 | 自动判定 tracker/priority/labels,关联 Issue,生成审计报告 |
|
||||
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、仓库初始化、Sprint 报告 |
|
||||
| **gitlink-webhook** | Webhook 管理 | `webhook +list`, `webhook +create`, `webhook +test` |
|
||||
| **gitlink-compliance** | 安全与合规 | `compliance +scan`, `compliance +secrets`, `compliance +license` |
|
||||
| **gitlink-onboard** | 新人引导 | `onboard +welcome` |
|
||||
| **gitlink-team** | 团队管理 | `team +list`, `team +create`, `team +add-member` |
|
||||
| **gitlink-contrib** | 贡献报告 | `contrib +report` |
|
||||
| **gitlink-code-insight** | 功能全景 | 全部 Shortcuts 分类索引,含说明和示例 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -210,6 +255,27 @@ gitlink-cli org +info -i Gitlink
|
|||
|
||||
详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
|
||||
|
||||
### 场景 5:管理 Wiki 文档
|
||||
|
||||
```bash
|
||||
# 列出 Wiki 页面
|
||||
gitlink-cli wiki +list --owner myuser --repo myrepo
|
||||
|
||||
# 查看页面内容
|
||||
gitlink-cli wiki +view --owner myuser --repo myrepo --title "Home"
|
||||
|
||||
# 创建页面(从文件)
|
||||
gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md
|
||||
|
||||
# 追加内容到现有页面
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --title "API 文档" --add "\n\n## 新增接口"
|
||||
|
||||
# 预览删除(不实际执行)
|
||||
gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run
|
||||
```
|
||||
|
||||
详见: [gitlink-wiki/SKILL.md](gitlink-wiki/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档导航
|
||||
|
|
@ -298,6 +364,7 @@ AI 代理可以:
|
|||
- ✅ 自动创建和管理 Issue
|
||||
- ✅ 自动创建和合并 PR
|
||||
- ✅ 自动发布 Release
|
||||
- ✅ 自动管理 Wiki 文档
|
||||
- ✅ 自动分类 Issue
|
||||
- ✅ 自动生成 Release Notes
|
||||
- ✅ 自动执行代码审查
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
# branch +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
从现有分支或 commit 创建新分支。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 从 master 创建分支
|
||||
gitlink-cli branch +create --name feature/new-feature
|
||||
|
||||
# 从指定分支创建
|
||||
gitlink-cli branch +create --name hotfix/bug-123 --from develop
|
||||
|
||||
# 从指定 commit 创建
|
||||
gitlink-cli branch +create --name feature/x --from abc123def
|
||||
|
||||
# 指定仓库创建分支
|
||||
gitlink-cli branch +create --name feature/x --owner someone --repo myrepo
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | **是** | 新分支名称 |
|
||||
| `--from, -f` | 否 | 源分支或 commit(默认 `master`) |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /v1/{owner}/{repo}/branches
|
||||
Body: { "new_branch_name": name, "old_branch_name": from }
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"name": "feature/new-feature",
|
||||
"commit_id": "abc123...",
|
||||
"commit_message": "Create feature branch",
|
||||
"committed_time": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the branch name and source branch with the user.
|
||||
2. **Execute** `gitlink-cli branch +create --name <name> --from <source>`.
|
||||
3. **Report** the created branch information.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent before executing.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **功能开发**:为新功能创建独立分支
|
||||
- **Bug 修复**:从稳定分支创建 hotfix 分支
|
||||
- **实验性功能**:创建实验分支进行尝试
|
||||
- **版本发布**:为发布版本创建分支
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **命名规范**:使用有意义的分支名,如 `feature/xxx`、`hotfix/xxx`、`release/xxx`
|
||||
- **源分支选择**:通常从 `develop` 或 `master` 创建功能分支
|
||||
- **分支描述**:创建后可以添加描述说明分支用途
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 仓库不存在 | 检查 `--owner` 和 `--repo` 是否正确 |
|
||||
| `409` | 分支已存在 | 使用不同的分支名或删除现有分支 |
|
||||
| `404` | 源分支不存在 | 确认 `--from` 指定的分支或 commit 存在 |
|
||||
|
||||
## Tips
|
||||
|
||||
- 默认从 `master` 分支创建,如需从其他分支创建需明确指定
|
||||
- 分支名支持 `/` 分隔符,便于组织分支结构
|
||||
- 创建后可以立即使用 `gitlink-cli branch +list` 验证
|
||||
|
||||
## References
|
||||
|
||||
- [branch +list](branch-list.md) — 列出分支
|
||||
- [branch +delete](branch-delete.md) — 删除分支
|
||||
- [gitlink-branch](../SKILL.md) — 分支操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# branch +delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除指定的分支。**此操作不可逆,请谨慎使用。**
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除分支
|
||||
gitlink-cli branch +delete --name feature/old-feature
|
||||
|
||||
# 指定仓库删除分支
|
||||
gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo
|
||||
|
||||
# 删除带路径的分支
|
||||
gitlink-cli branch +delete --name feature/my-feature
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | **是** | 要删除的分支名称 |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /v1/{owner}/{repo}/branches/delete
|
||||
Body: { "branch_name": name }
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"message": "Branch deleted successfully",
|
||||
"branch_name": "feature/old-feature"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the user really wants to delete this branch (emphasize this is **irreversible**).
|
||||
2. **Check** if the branch exists using `branch +list` if needed.
|
||||
3. **Execute** `gitlink-cli branch +delete --name <name>`.
|
||||
4. **Report** the deletion result.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Destructive Operation** — confirm user intent before executing. This action **cannot be undone**.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **清理已完成的功能分支**:功能合并后删除功能分支
|
||||
- **清理错误的分支**:删除创建错误或不再需要的分支
|
||||
- **维护分支整洁**:定期清理无用分支保持仓库整洁
|
||||
|
||||
## Warnings
|
||||
|
||||
- ⚠️ **不可逆操作**:删除分支后无法恢复
|
||||
- ⚠️ **受保护分支**:无法删除受保护的分支
|
||||
- ⚠️ **默认分支**:无法删除默认分支(通常是 master)
|
||||
- ⚠️ **未合并更改**:删除包含未合并更改的分支可能导致代码丢失
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **确认合并状态**:删除前确认分支的更改已经合并
|
||||
2. **备份重要更改**:如果有重要更改未合并,先备份或合并
|
||||
3. **沟通确认**:团队协作时先沟通确认再删除
|
||||
4. **使用描述性名称**:避免删除错误的分支
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 |
|
||||
| `403` | 权限不足 | 确认有删除分支的权限 |
|
||||
| `400` | 受保护分支 | 无法删除受保护的分支 |
|
||||
| `400` | 默认分支 | 无法删除默认分支 |
|
||||
|
||||
## Safety Checks
|
||||
|
||||
建议在删除前执行以下检查:
|
||||
|
||||
```bash
|
||||
# 1. 检查分支是否存在
|
||||
gitlink-cli branch +list | grep branch-name
|
||||
|
||||
# 2. 确认不是保护分支
|
||||
gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="branch-name") | .protected'
|
||||
|
||||
# 3. 确认不是默认分支
|
||||
gitlink-cli api GET /{owner}/{repo} | jq '.data.default_branch'
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- 删除前建议使用 `gitlink-cli branch +list` 确认分支名称
|
||||
- 对于重要分支,建议先检查是否有未合并的 PR
|
||||
- 团队协作时,删除公共分支前先通知团队成员
|
||||
|
||||
## References
|
||||
|
||||
- [branch +list](branch-list.md) — 列出分支
|
||||
- [branch +create](branch-create.md) — 创建分支
|
||||
- [branch +protect](branch-protect.md) — 保护分支
|
||||
- [gitlink-branch](../SKILL.md) — 分支操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# branch +list
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出仓库的所有分支,支持分页查询。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出当前仓库的分支
|
||||
gitlink-cli branch +list
|
||||
|
||||
# 指定仓库并分页
|
||||
gitlink-cli branch +list --owner Gitlink --repo forgeplus --page 1 --limit 10
|
||||
|
||||
# 输出为 JSON
|
||||
gitlink-cli branch +list --format json
|
||||
|
||||
# 输出为 YAML
|
||||
gitlink-cli branch +list --format yaml
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--page, -p` | 否 | 页码(默认 `1`) |
|
||||
| `--limit, -l` | 否 | 每页条数(默认 `20`) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /v1/{owner}/{repo}/branches?page=1&limit=20
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"branches": [
|
||||
{
|
||||
"name": "master",
|
||||
"commit_id": "abc123...",
|
||||
"commit_message": "Initial commit",
|
||||
"committed_time": "2026-01-01T00:00:00Z",
|
||||
"is_default": true,
|
||||
"protected": false
|
||||
},
|
||||
{
|
||||
"name": "develop",
|
||||
"commit_id": "def456...",
|
||||
"commit_message": "Develop branch",
|
||||
"committed_time": "2026-01-02T00:00:00Z",
|
||||
"is_default": false,
|
||||
"protected": true
|
||||
}
|
||||
],
|
||||
"total_count": 15
|
||||
},
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total_count": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Resolve** owner and repo (from git remote or flags).
|
||||
2. **Execute** `gitlink-cli branch +list`.
|
||||
3. **Display** branches in the requested format.
|
||||
|
||||
> [!NOTE]
|
||||
> This is a **Read Operation** — no confirmation needed.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **查看可用分支**:在创建 PR 前查看所有分支
|
||||
- **检查分支保护状态**:查看哪些分支被保护
|
||||
- **分支浏览**:探索仓库的分支结构
|
||||
- **自动化脚本**:结合 JSON 格式输出进行批量操作
|
||||
|
||||
## Tips
|
||||
|
||||
- 使用 `--format json` 可以更好地解析分支信息
|
||||
- 分支列表包含保护状态,可以快速识别受保护的分支
|
||||
- 支持分页,适合分支较多的仓库
|
||||
|
||||
## References
|
||||
|
||||
- [branch +create](branch-create.md) — 创建分支
|
||||
- [branch +protect](branch-protect.md) — 保护分支
|
||||
- [gitlink-branch](../SKILL.md) — 分支操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# branch +protect
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
设置分支保护规则,防止重要分支被意外修改或删除。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 保护分支
|
||||
gitlink-cli branch +protect --name main
|
||||
|
||||
# 保护 master 分支
|
||||
gitlink-cli branch +protect --name master
|
||||
|
||||
# 指定仓库保护分支
|
||||
gitlink-cli branch +protect --name main --owner someone --repo myrepo
|
||||
|
||||
# 保护开发分支
|
||||
gitlink-cli branch +protect --name develop
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | **是** | 要保护的分支名称 |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /{owner}/{repo}/protected_branches
|
||||
Body: { "branch_name": name }
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"branch_name": "main",
|
||||
"protected": true,
|
||||
"message": "Branch protection enabled successfully"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the branch name to protect with the user.
|
||||
2. **Execute** `gitlink-cli branch +protect --name <name>`.
|
||||
3. **Report** the protection result.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent before executing.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **保护主分支**:保护 `main` 或 `master` 分支,防止直接推送
|
||||
- **保护发布分支**:保护 `release` 分支,确保发布版本稳定性
|
||||
- **保护开发分支**:保护 `develop` 分支,维护开发主线稳定
|
||||
- **合规要求**:满足团队管理或合规性要求
|
||||
|
||||
## What Protection Means
|
||||
|
||||
分支保护后,以下操作将被限制:
|
||||
- ✅ **仍可操作**:通过 Pull Request 合并更改
|
||||
- ❌ **受限操作**:直接推送代码
|
||||
- ❌ **受限操作**:强制推送
|
||||
- ❌ **受限操作**:删除分支
|
||||
- ❌ **受限操作**:修改历史
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **保护关键分支**:至少保护 `main` 和 `develop` 分支
|
||||
2. **配合 PR 工作流**:强制通过 PR 进行代码审查
|
||||
3. **定期审查**:定期检查和保护重要的分支
|
||||
4. **团队协作**:团队协商确定保护策略
|
||||
|
||||
## Common Protected Branches
|
||||
|
||||
| 分支名 | 用途 | 建议保护 |
|
||||
|--------|------|----------|
|
||||
| `main` / `master` | 主分支 | ✅ 强烈建议 |
|
||||
| `develop` | 开发分支 | ✅ 建议 |
|
||||
| `release/*` | 发布分支 | ✅ 建议 |
|
||||
| `hotfix/*` | 紧急修复分支 | ⚠️ 可选 |
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 |
|
||||
| `403` | 权限不足 | 确认有管理权限 |
|
||||
| `409` | 已经被保护 | 分支已经处于保护状态 |
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
- ⚠️ **权限要求**:需要管理员或协作者权限
|
||||
- ⚠️ **团队影响**:保护分支影响整个团队的协作流程
|
||||
- ⚠️ **CI/CD 集成**:确保 CI/CD 流程兼容保护规则
|
||||
|
||||
## Tips
|
||||
|
||||
- 保护前先确认分支名称正确
|
||||
- 可以使用 `branch +list --format json` 查看分支保护状态
|
||||
- 设置保护后,团队成员需要通过 PR 贡献代码
|
||||
- 建议在设置保护前通知团队成员
|
||||
|
||||
## Workflow Example
|
||||
|
||||
典型的分支保护工作流:
|
||||
|
||||
```bash
|
||||
# 1. 查看分支列表
|
||||
gitlink-cli branch +list
|
||||
|
||||
# 2. 确认要保护的分支
|
||||
gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main")'
|
||||
|
||||
# 3. 设置分支保护
|
||||
gitlink-cli branch +protect --name main
|
||||
|
||||
# 4. 验证保护设置
|
||||
gitlink-cli branch +list --format json | jq '.data.branchs[] | select(.name=="main") | .protected'
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [branch +unprotect](branch-unprotect.md) — 移除分支保护
|
||||
- [branch +list](branch-list.md) — 列出分支并查看保护状态
|
||||
- [gitlink-branch](../SKILL.md) — 分支操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# branch +unprotect
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
移除分支保护规则,允许分支被直接修改。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 移除分支保护
|
||||
gitlink-cli branch +unprotect --name main
|
||||
|
||||
# 指定仓库移除分支保护
|
||||
gitlink-cli branch +unprotect --name main --owner someone --repo myrepo
|
||||
|
||||
# 移除开发分支保护
|
||||
gitlink-cli branch +unprotect --name develop
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | **是** | 要移除保护的分支名称 |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
DELETE /{owner}/{repo}/protected_branches/{branch_name}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"branch_name": "main",
|
||||
"protected": false,
|
||||
"message": "Branch protection removed successfully"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the branch name to unprotect with the user.
|
||||
2. **Execute** `gitlink-cli branch +unprotect --name <name>`.
|
||||
3. **Report** the unprotection result.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent before executing. This will allow direct pushes to the branch.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **紧急修复**:临时允许直接推送紧急修复
|
||||
- **分支重组**:调整分支保护策略
|
||||
- **迁移工作流**:从 PR 工作流切换到直接推送
|
||||
- **权限调整**:根据团队需求调整保护规则
|
||||
|
||||
## What Unprotection Means
|
||||
|
||||
移除分支保护后,以下操作将被允许:
|
||||
- ✅ **允许操作**:直接推送代码
|
||||
- ✅ **允许操作**:强制推送
|
||||
- ✅ **允许操作**:删除分支
|
||||
- ✅ **允许操作**:修改历史
|
||||
|
||||
## Risks and Considerations
|
||||
|
||||
⚠️ **风险提醒**:
|
||||
- 失去 PR 代码审查机制
|
||||
- 可能直接推送到关键分支
|
||||
- 增加代码冲突和错误风险
|
||||
- 影响代码质量和稳定性
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **谨慎使用**:仅在确有需要时移除保护
|
||||
2. **临时移除**:考虑临时移除后重新保护
|
||||
3. **团队沟通**:移除保护前通知团队成员
|
||||
4. **重新保护**:完成操作后及时恢复保护
|
||||
|
||||
## When to Use
|
||||
|
||||
**适合移除保护的情况:**
|
||||
- 紧急修复需要快速部署
|
||||
- 仓库结构重组或迁移
|
||||
- 测试和验证工作流
|
||||
- 小团队内部协作
|
||||
|
||||
**不适合移除保护的情况:**
|
||||
- 有 PR 审查需求
|
||||
- 多人协作的大型项目
|
||||
- 需要严格代码质量控制
|
||||
- 生产环境的关键分支
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 |
|
||||
| `404` | 未被保护 | 分支当前没有保护规则 |
|
||||
| `403` | 权限不足 | 确认有管理权限 |
|
||||
| `400` | 路径问题 | 含 `/` 的分支名可能需要通过 Web 操作 |
|
||||
|
||||
## Limitations
|
||||
|
||||
- ⚠️ **路径限制**:含 `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 移除保护
|
||||
- ⚠️ **API 限制**:某些特殊分支可能需要通过 Web 页面操作
|
||||
- ⚠️ **权限要求**:需要管理员或协作者权限
|
||||
|
||||
## Safety Workflow
|
||||
|
||||
推荐的移除保护工作流:
|
||||
|
||||
```bash
|
||||
# 1. 查看当前保护状态
|
||||
gitlink-cli branch +list --format json | jq '.data.branches[] | select(.protected)'
|
||||
|
||||
# 2. 确认要移除保护的分支
|
||||
gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected'
|
||||
|
||||
# 3. 移除分支保护
|
||||
gitlink-cli branch +unprotect --name main
|
||||
|
||||
# 4. 验证移除结果
|
||||
gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected'
|
||||
|
||||
# 5. 完成操作后重新保护
|
||||
gitlink-cli branch +protect --name main
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- 移除保护前,建议先检查当前的保护状态
|
||||
- 考虑设置定时提醒,确保及时恢复保护
|
||||
- 对于重要分支,建议使用 Web UI 确认移除保护
|
||||
- 记录移除保护的原因和时间,便于审计
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
团队协作时的建议:
|
||||
|
||||
1. **提前沟通**:在移除保护前通知所有团队成员
|
||||
2. **说明原因**:向团队解释为什么需要移除保护
|
||||
3. **时间限制**:设定移除保护的时间限制
|
||||
4. **操作文档**:记录移除保护的操作和原因
|
||||
5. **及时恢复**:完成操作后立即恢复保护
|
||||
|
||||
## References
|
||||
|
||||
- [branch +protect](branch-protect.md) — 设置分支保护
|
||||
- [branch +list](branch-list.md) — 列出分支并查看保护状态
|
||||
- [gitlink-branch](../SKILL.md) — 分支操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
name: gitlink-changelog
|
||||
version: 1.0.0
|
||||
description: "Release Notes 生成:根据 commit 和 PR 记录自动生成结构化版本发布说明。当用户需要生成 Release Notes、发版说明时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli release --help"
|
||||
---
|
||||
|
||||
# gitlink-changelog(Release Notes 生成)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 工作流
|
||||
|
||||
Release Notes 生成分三步:收集 → 分类 → 发布。
|
||||
|
||||
| 步骤 | 说明 | 所用命令 |
|
||||
|------|------|----------|
|
||||
| 1. 收集数据 | 获取版本间的 commits、已合并 PR、已关闭 Issue | `release +list`, `api GET compare`, `pr +list`, `issue +list` |
|
||||
| 2. 分类整理 | 按类型归类变更(新功能/Bug修复/改进/破坏性变更) | AI 分析 |
|
||||
| 3. 生成发布 | 套用模板生成 Notes,创建或更新 Release | `release +create`, `release +update` |
|
||||
|
||||
## 命令参考
|
||||
|
||||
### 收集数据
|
||||
|
||||
```bash
|
||||
# 确定版本范围:获取已有 release 列表,找上一个 tag
|
||||
gitlink-cli release +list --format json
|
||||
|
||||
# 获取两个版本间的 commit 差异(平台 compare API)
|
||||
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
|
||||
|
||||
# 获取已合并的 PR
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# 获取已关闭的 Issue
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
```
|
||||
|
||||
### 发布 Release Notes
|
||||
|
||||
```bash
|
||||
# 创建 Release 并附带 Notes
|
||||
gitlink-cli release +create --tag v1.1.0 --name "v1.1.0" --body "<Markdown 格式的 Release Notes>"
|
||||
|
||||
# 更新已有 Release 的 Notes
|
||||
gitlink-cli release +update --id <version_id> --body "<更新后的 Notes>"
|
||||
```
|
||||
|
||||
## 分类规则
|
||||
|
||||
| 类型 | 图标 | Issue 标签 | Commit 关键词 |
|
||||
|------|------|------------|---------------|
|
||||
| 新功能 | ✨ | `feature`, `enhancement` | `feat:`, `add`, `新增` |
|
||||
| Bug 修复 | 🐛 | `bug`, `fix` | `fix:`, `bugfix`, `修复` |
|
||||
| 功能改进 | 🔧 | `improvement`, `optimize` | `improve:`, `optimize:`, `refactor:` |
|
||||
| 破坏性变更 | ⚠️ | `breaking`, `major` | `BREAKING`, `breaking:`, `!` |
|
||||
| 文档 | 📝 | `docs`, `documentation` | `docs:`, `doc` |
|
||||
| 安全修复 | 🔒 | `security`, `vulnerability` | `security:`, `安全` |
|
||||
|
||||
## Release Notes 模板
|
||||
|
||||
### 标准模板
|
||||
|
||||
```markdown
|
||||
# 🎉 Release {VERSION}
|
||||
|
||||
## 📊 变更统计
|
||||
- **新功能**: {FEATURE_COUNT} 个
|
||||
- **Bug 修复**: {BUG_FIX_COUNT} 个
|
||||
- **功能改进**: {ENHANCEMENT_COUNT} 个
|
||||
- **破坏性变更**: {BREAKING_COUNT} 个
|
||||
|
||||
## ✨ 新功能
|
||||
{FEATURES}
|
||||
|
||||
## 🐛 Bug 修复
|
||||
{BUG_FIXES}
|
||||
|
||||
## 🔧 功能改进
|
||||
{ENHANCEMENTS}
|
||||
|
||||
## ⚠️ 破坏性变更
|
||||
{BREAKING_CHANGES}
|
||||
|
||||
## 🙏 贡献者
|
||||
{CONTRIBUTORS}
|
||||
|
||||
---
|
||||
**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}
|
||||
```
|
||||
|
||||
> **条目格式要求**:每个变更条目必须在末尾标注作者,格式为 `- 变更描述 (#编号) (@作者)`。commit 无 PR 编号时格式为 `- 变更描述 (作者名)`。
|
||||
|
||||
### 简化模板
|
||||
|
||||
```markdown
|
||||
# {VERSION}
|
||||
|
||||
## 新增
|
||||
{FEATURES}
|
||||
|
||||
## 修复
|
||||
{BUG_FIXES}
|
||||
|
||||
## 改进
|
||||
{ENHANCEMENTS}
|
||||
```
|
||||
|
||||
## 版本号规范
|
||||
|
||||
遵循语义化版本(Semantic Versioning):`MAJOR.MINOR.PATCH`
|
||||
|
||||
| 变更类型 | 版本变化 | 示例 |
|
||||
|----------|----------|------|
|
||||
| 破坏性变更 | MAJOR +1 | `1.2.0` → `2.0.0` |
|
||||
| 向后兼容的新功能 | MINOR +1 | `1.1.0` → `1.2.0` |
|
||||
| 向后兼容的 Bug 修复 | PATCH +1 | `1.1.0` → `1.1.1` |
|
||||
|
||||
## API 注意事项
|
||||
|
||||
- `compare` API 无专用 shortcut,通过 `gitlink-cli api GET /:owner/:repo/compare/{head}...{base}` 调用
|
||||
- `compare` API 另支持查询参数格式:`GET /v1/:owner/:repo/compare.json?from=&to=`
|
||||
- `release +create` 的 `--body` 接受完整 Markdown,支持多行文本
|
||||
- `release +view` / `release +delete` 必须使用 `version_id`(从 `release +list` 获取),不可用 `tag_name`
|
||||
- 创建 Release 前务必让用户审核生成的 Notes 内容
|
||||
|
||||
## References
|
||||
|
||||
- [collect-data](references/collect-data.md) — 收集 commits、PR、Issue 数据
|
||||
- [classify-rules](references/classify-rules.md) — 变更分类规则详解
|
||||
- [generate-and-publish](references/generate-and-publish.md) — 生成 Notes 并发布
|
||||
- [full-workflow](examples/full-workflow.md) — 完整端到端示例
|
||||
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
# Release Notes 完整生成示例
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
> **适用场景:** AI Agent 端到端生成 Release Notes,从数据收集到发布。
|
||||
|
||||
以 `zzx-coder/gitlink-cli` 项目从 `v1.0.0` 到 `v1.1.0` 为例。
|
||||
|
||||
## 完整流程
|
||||
|
||||
### 第一步:确定版本范围
|
||||
|
||||
```bash
|
||||
# 获取已有 release
|
||||
gitlink-cli release +list --format json
|
||||
|
||||
# 返回示例(截取关键字段):
|
||||
# {
|
||||
# "ok": true,
|
||||
# "data": {
|
||||
# "releases": [
|
||||
# { "tag_name": "v1.0.0", "created_at": "2026-05-01", ... },
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
|
||||
# AI 据此确定:PREV_VERSION = "v1.0.0",NEW_VERSION = "v1.1.0"
|
||||
```
|
||||
|
||||
### 第二步:收集 commits
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
|
||||
|
||||
# 从返回中提取 commits 列表,每个 commit 含:
|
||||
# - commit.message (提交信息)
|
||||
# - commit.author.name (作者)
|
||||
# - sha (提交 SHA)
|
||||
```
|
||||
|
||||
### 第三步:收集已合并 PR
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# AI 筛选 merged_at >= "2026-05-01"(v1.0.0 发布时间)的 PR
|
||||
# 提取每个 PR 的 title、number、author.login
|
||||
```
|
||||
|
||||
### 第四步:收集已关闭 Issue
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
|
||||
# AI 筛选 closed_at >= "2026-05-01" 的 Issue
|
||||
# 提取每个 Issue 的 subject、project_issues_index、issue_tags
|
||||
```
|
||||
|
||||
### 第五步:AI 分类
|
||||
|
||||
AI 根据 [分类规则](../references/classify-rules.md) 对收集到的数据分类:
|
||||
|
||||
```
|
||||
新功能:
|
||||
- 支持批量 Issue 操作 (#12) (@zhangsan)
|
||||
- 新增 uninstall 命令 (#11) (@lisi)
|
||||
|
||||
Bug 修复:
|
||||
- 修复 URL 解析异常 (#10) (@wangwu)
|
||||
|
||||
功能改进:
|
||||
- 重构自动部署配置 (camelliamc)
|
||||
|
||||
文档:
|
||||
- 更新分支映射说明 (camelliamc)
|
||||
```
|
||||
|
||||
### 第六步:生成 Notes 并确认
|
||||
|
||||
AI 套用标准模板生成草稿并展示给用户:
|
||||
|
||||
```markdown
|
||||
# 🎉 Release v1.1.0
|
||||
|
||||
## 📊 变更统计
|
||||
- **新功能**: 2 个
|
||||
- **Bug 修复**: 1 个
|
||||
- **功能改进**: 1 个
|
||||
- **破坏性变更**: 0 个
|
||||
|
||||
## ✨ 新功能
|
||||
- 支持批量 Issue 操作 (#12) (@zhangsan)
|
||||
- 新增 uninstall 命令 (#11) (@lisi)
|
||||
|
||||
## 🐛 Bug 修复
|
||||
- 修复 URL 解析异常 (#10) (@wangwu)
|
||||
|
||||
## 🔧 功能改进
|
||||
- 重构自动部署配置 (camelliamc)
|
||||
|
||||
## 🙏 贡献者
|
||||
zzx-coder, camelliamc
|
||||
|
||||
---
|
||||
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0
|
||||
```
|
||||
|
||||
### 第七步:用户确认后发布
|
||||
|
||||
```bash
|
||||
gitlink-cli release +create \
|
||||
--tag v1.1.0 \
|
||||
--name "v1.1.0" \
|
||||
--body "# 🎉 Release v1.1.0
|
||||
|
||||
## 📊 变更统计
|
||||
- **新功能**: 2 个
|
||||
- **Bug 修复**: 1 个
|
||||
- **功能改进**: 1 个
|
||||
- **破坏性变更**: 0 个
|
||||
|
||||
## ✨ 新功能
|
||||
- 支持批量 Issue 操作 (#12) (@zhangsan)
|
||||
- 新增 uninstall 命令 (#11) (@lisi)
|
||||
|
||||
## 🐛 Bug 修复
|
||||
- 修复 URL 解析异常 (#10) (@wangwu)
|
||||
|
||||
## 🔧 功能改进
|
||||
- 重构自动部署配置 (camelliamc)
|
||||
|
||||
## 🙏 贡献者
|
||||
zhangsan, lisi, wangwu, camelliamc
|
||||
|
||||
---
|
||||
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0"
|
||||
```
|
||||
|
||||
## AI Agent 执行要点
|
||||
|
||||
1. **自动解析 `--owner` / `--repo`**:在仓库目录下执行,CLI 自动从 git remote 解析
|
||||
2. **始终使用 `--format json`**:所有命令加此参数,便于 AI 解析返回值
|
||||
3. **时间筛选**:用上一个 Release 的 `created_at` 作为 PR/Issue 的时间筛选基线
|
||||
4. **去重**:PR 和 Issue 描述同一变更时合并为一条
|
||||
5. **确认优先**:生成 Notes 后必须展示给用户,收到确认才执行 `release +create`
|
||||
|
||||
## References
|
||||
|
||||
- [SKILL.md](../SKILL.md) — 工作流和模板总览
|
||||
- [collect-data](../references/collect-data.md) — 数据收集详细说明
|
||||
- [classify-rules](../references/classify-rules.md) — 分类规则
|
||||
- [generate-and-publish](../references/generate-and-publish.md) — 生成和发布
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
# 变更分类规则
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将收集到的 commits、PRs 和 Issues 按类型归类,为生成结构化 Release Notes 做准备。
|
||||
|
||||
## 分类维度
|
||||
|
||||
变更按以下维度分类:
|
||||
|
||||
| 类型 | 图标 | 标题 |
|
||||
|------|------|------|
|
||||
| 新功能 | ✨ | 新功能 |
|
||||
| Bug 修复 | 🐛 | Bug 修复 |
|
||||
| 功能改进 | 🔧 | 功能改进 |
|
||||
| 破坏性变更 | ⚠️ | 破坏性变更 |
|
||||
| 文档 | 📝 | 文档 |
|
||||
| 安全修复 | 🔒 | 安全修复 |
|
||||
|
||||
## 分类依据
|
||||
|
||||
### 按 Issue 标签分类(最可靠)
|
||||
|
||||
从 `issue +list` 返回的 `issue_tags` 字段匹配:
|
||||
|
||||
| Issue 标签 | 对应类型 |
|
||||
|------------|----------|
|
||||
| `feature`, `enhancement` | 新功能 |
|
||||
| `bug`, `fix` | Bug 修复 |
|
||||
| `improvement`, `optimize` | 功能改进 |
|
||||
| `breaking`, `major` | 破坏性变更 |
|
||||
| `docs`, `documentation` | 文档 |
|
||||
| `security`, `vulnerability` | 安全修复 |
|
||||
|
||||
### 按 Commit 关键词分类(辅助)
|
||||
|
||||
从 `compare` API 返回的 `commit.message` 第一行匹配:
|
||||
|
||||
| 关键词 | 对应类型 |
|
||||
|--------|----------|
|
||||
| `feat:`, `add`, `新增` | 新功能 |
|
||||
| `fix:`, `bugfix`, `修复` | Bug 修复 |
|
||||
| `improve:`, `optimize:`, `refactor:`, `优化`, `重构` | 功能改进 |
|
||||
| `BREAKING`, `breaking:`, `!` | 破坏性变更 |
|
||||
| `docs:`, `doc` | 文档 |
|
||||
| `security:`, `安全` | 安全修复 |
|
||||
|
||||
### 按 PR 标题分类(辅助)
|
||||
|
||||
PR 标题通常遵循 Conventional Commits 格式,按前缀匹配:
|
||||
|
||||
| PR 标题前缀 | 对应类型 |
|
||||
|-------------|----------|
|
||||
| `feat:` / `feature:` | 新功能 |
|
||||
| `fix:` | Bug 修复 |
|
||||
| `refactor:` / `perf:` | 功能改进 |
|
||||
| `docs:` | 文档 |
|
||||
|
||||
## 分类优先级
|
||||
|
||||
1. **Issue 标签**(最准确,优先采用)
|
||||
2. **PR 标题前缀**(次之)
|
||||
3. **Commit 关键词**(兜底)
|
||||
|
||||
对于同一个变更,如果 Issue 标签和 commit 关键词都在,以 Issue 标签为准。
|
||||
|
||||
## 去重
|
||||
|
||||
以下情况会产生重复条目,需去重:
|
||||
|
||||
- PR 和 Issue 关联同一个变更 → 合并为一条:`变更描述 (#PR编号, #Issue编号)`
|
||||
- 同一变更的多个 commit → 只保留摘要最清晰的一条
|
||||
- PR 标题与关联 Issue 主题高度相似 → 合并,优先使用 Issue 的 subject
|
||||
|
||||
## 输出格式
|
||||
|
||||
分类完成后,整理为结构化数据供模板填充:
|
||||
|
||||
```
|
||||
新功能:
|
||||
- 支持批量关闭 Issue (#123)
|
||||
- 新增 Wiki 管理命令 (#130)
|
||||
|
||||
Bug 修复:
|
||||
- 修复 Windows 登录 token 存储失败 (#118)
|
||||
|
||||
功能改进:
|
||||
- 优化 API 请求性能 (#125)
|
||||
|
||||
破坏性变更:
|
||||
- 重构认证模块接口(不向下兼容)(#140)
|
||||
|
||||
文档:
|
||||
- 补充分支映射说明 (#115)
|
||||
```
|
||||
|
||||
## 贡献者收集
|
||||
|
||||
从 commit 和 PR 数据中提取贡献者列表:
|
||||
|
||||
- Commit: `author.name` 或 `author.login`
|
||||
- PR: `author.login`
|
||||
|
||||
去重后生成贡献者名单,写入 Release Notes 末尾。
|
||||
|
||||
## References
|
||||
|
||||
- [collect-data](collect-data.md) — 数据收集步骤
|
||||
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
|
||||
- [SKILL.md](../SKILL.md) — 分类规则速查表
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# 收集变更数据
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
收集 Release Notes 所需的三类数据:版本范围、commits、PRs 和 Issues。
|
||||
|
||||
## 命令
|
||||
|
||||
### 步骤 1:确定版本范围
|
||||
|
||||
```bash
|
||||
# 获取已有 release 列表,找到上一个版本 tag
|
||||
gitlink-cli release +list --format json
|
||||
# 从返回的 releases 中提取最后一个 tag_name 作为 PREV_VERSION
|
||||
# 用户指定或 AI 推断新版本号 NEW_VERSION
|
||||
```
|
||||
|
||||
### 步骤 2:获取 commits(平台 compare API)
|
||||
|
||||
```bash
|
||||
# 获取两个 tag 之间的 commit 比较
|
||||
gitlink-cli api GET /:owner/:repo/compare/{PREV_VERSION}...{NEW_VERSION} --format json
|
||||
|
||||
# 也可用查询参数格式
|
||||
gitlink-cli api GET /v1/:owner/:repo/compare.json --query "from={PREV_VERSION}&to={NEW_VERSION}" --format json
|
||||
```
|
||||
|
||||
返回数据包含:`commits`(提交列表含 message/author/date/sha)、`total_commits`(提交总数)、`files`(变更文件)等。
|
||||
|
||||
### 步骤 3:获取已合并的 PR
|
||||
|
||||
```bash
|
||||
# 获取已合并的 PR 列表
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# 从返回的 PRs 中按 merged_at 时间筛选:
|
||||
# 只保留 merged_at >= 上一个版本发布时间的 PR
|
||||
```
|
||||
|
||||
返回数据包含:每个 PR 的 `title`、`number`、`author`、`merged_at`、`pull_request_number` 等。
|
||||
|
||||
### 步骤 4:获取已关闭的 Issue
|
||||
|
||||
```bash
|
||||
# 获取已关闭的 Issue 列表
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
|
||||
# 从返回的 Issues 中按 closed_at 时间筛选:
|
||||
# 只保留 closed_at >= 上一个版本发布时间的 Issue
|
||||
```
|
||||
|
||||
返回数据包含:每个 Issue 的 `subject`、`project_issues_index`、`issue_tags`(标签)、`author`、`closed_at` 等。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--format` | 否 | 始终建议 `json`,便于 AI 解析 |
|
||||
| `--state` | 否 | PR: `merged`;Issue: `closed` |
|
||||
| `--page` | 否 | 大量数据时分页获取 |
|
||||
| `--limit` | 否 | 每页条数 |
|
||||
|
||||
## 数据整合
|
||||
|
||||
收集完成后,AI 整合三类数据:
|
||||
|
||||
1. **Commits** → 提取 commit message 第一行作为变更摘要,附作者名
|
||||
2. **PRs** → 用 `title`、`number`、`author.login` 生成条目:`- 功能描述 (#PR编号) (@作者)`
|
||||
3. **Issues** → 用 `subject`、`project_issues_index`、`author.login` 生成条目:`- Issue 描述 (#编号) (@作者)`
|
||||
|
||||
时间筛选逻辑:从 `release +list` 获取上一个版本的发布时间,只取该时间之后的 PR/Issue。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 如果是**第一个版本**(无上一版本),只收集当前版本时间范围内的 PR/Issue,commits 用全量最近提交
|
||||
- `compare` API 的 tag 需要真实存在,否则返回 404
|
||||
- PR 和 Issue 的返回可能超过单页,注意分页获取全部数据
|
||||
- `--owner` / `--repo` 在仓库目录下可自动解析
|
||||
|
||||
## References
|
||||
|
||||
- [classify-rules](classify-rules.md) — 收集完成后对变更进行分类
|
||||
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
|
||||
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# 生成并发布 Release Notes
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
> **CRITICAL — 此为写入操作,执行前务必确认用户已审核 Release Notes 内容。**
|
||||
|
||||
将分类整理后的变更数据套用模板,生成 Markdown 格式的 Release Notes,并发布到 GitLink。
|
||||
|
||||
## 模板
|
||||
|
||||
### 标准模板
|
||||
|
||||
```markdown
|
||||
# 🎉 Release {VERSION}
|
||||
|
||||
## 📊 变更统计
|
||||
- **新功能**: {FEATURE_COUNT} 个
|
||||
- **Bug 修复**: {BUG_FIX_COUNT} 个
|
||||
- **功能改进**: {ENHANCEMENT_COUNT} 个
|
||||
- **破坏性变更**: {BREAKING_COUNT} 个
|
||||
|
||||
## ✨ 新功能
|
||||
{FEATURES}
|
||||
|
||||
## 🐛 Bug 修复
|
||||
{BUG_FIXES}
|
||||
|
||||
## 🔧 功能改进
|
||||
{ENHANCEMENTS}
|
||||
|
||||
## ⚠️ 破坏性变更
|
||||
{BREAKING_CHANGES}
|
||||
|
||||
## 🙏 贡献者
|
||||
{CONTRIBUTORS}
|
||||
|
||||
---
|
||||
**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}
|
||||
```
|
||||
|
||||
### 简化模板(适用于 patch 版本或小型发布)
|
||||
|
||||
```markdown
|
||||
# {VERSION}
|
||||
|
||||
## 新增
|
||||
{FEATURES}
|
||||
|
||||
## 修复
|
||||
{BUG_FIXES}
|
||||
|
||||
## 改进
|
||||
{ENHANCEMENTS}
|
||||
|
||||
## 贡献者
|
||||
{CONTRIBUTORS}
|
||||
|
||||
[完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION})
|
||||
```
|
||||
|
||||
## 模板占位符说明
|
||||
|
||||
| 占位符 | 来源 |
|
||||
|--------|------|
|
||||
| `{VERSION}` | 用户指定或 AI 推断的新版本号(如 `v1.2.0`) |
|
||||
| `{PREV}` | `release +list` 获取的上一个版本 tag |
|
||||
| `{OWNER}` | 仓库所有者,从 git remote 解析 |
|
||||
| `{REPO}` | 仓库名称,从 git remote 解析 |
|
||||
| `{FEATURE_COUNT}` 等 | 分类后的各类变更数量 |
|
||||
| `{FEATURES}` 等 | 分类后的各类变更条目,每条一行 `- 描述 (#编号) (@作者)` |
|
||||
| `{CONTRIBUTORS}` | 从 commits/PRs 去重后的贡献者列表 |
|
||||
|
||||
## 命令
|
||||
|
||||
### 新建 Release
|
||||
|
||||
```bash
|
||||
gitlink-cli release +create \
|
||||
--tag v1.2.0 \
|
||||
--name "v1.2.0" \
|
||||
--body "<Markdown 格式的 Release Notes>"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--tag` | **是** | 版本 tag(如 `v1.2.0`) |
|
||||
| `--name` | **是** | Release 名称,通常与 tag 一致 |
|
||||
| `--body` | 否 | Release Notes 正文(Markdown,支持多行) |
|
||||
| `--target` | 否 | 目标分支,默认 `master` |
|
||||
| `--prerelease` | 否 | 标记为预发布版本 |
|
||||
|
||||
### 更新已有 Release
|
||||
|
||||
```bash
|
||||
gitlink-cli release +update \
|
||||
--id <version_id> \
|
||||
--body "<更新后的 Release Notes>"
|
||||
```
|
||||
|
||||
> ⚠️ `release +update` 使用 `version_id`(数字 ID,从 `release +list` 获取),不是 `tag_name`。
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> Release Notes 发布是 **Write Operation**,执行前必须让用户审核内容。
|
||||
|
||||
1. **生成** Release Notes 草稿(套用模板填充数据)
|
||||
2. **展示**草稿给用户审核
|
||||
3. **确认**用户同意后才执行 `release +create` 或 `release +update`
|
||||
4. **报告**创建的 Release URL 给用户
|
||||
|
||||
## 发布前检查清单
|
||||
|
||||
- [ ] 版本号遵循语义化版本规范
|
||||
- [ ] 变更统计与实际一致
|
||||
- [ ] 破坏性变更已明确标注
|
||||
- [ ] 贡献者列表完整
|
||||
- [ ] 无敏感信息泄露
|
||||
- [ ] 对比链接可访问
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `release +create --body` 接受完整 Markdown,换行和格式由模板控制
|
||||
- `release +view` / `release +delete` 使用 `version_id`(数字),不是 `tag_name`
|
||||
- 可用 `release +update --body` 修正已发布的 Notes
|
||||
- 首个版本(无上一版本)省略对比链接
|
||||
|
||||
## References
|
||||
|
||||
- [collect-data](collect-data.md) — 收集变更数据
|
||||
- [classify-rules](classify-rules.md) — 变更分类规则
|
||||
- [full-workflow](../examples/full-workflow.md) — 完整端到端示例
|
||||
- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作
|
||||
- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release 详细参数
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
# ci +builds
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出仓库的所有 CI/CD 构建记录,支持分页查询。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看当前仓库的构建列表
|
||||
gitlink-cli ci +builds
|
||||
|
||||
# 指定仓库查看构建
|
||||
gitlink-cli ci +builds --owner myuser --repo myrepo
|
||||
|
||||
# 分页查询
|
||||
gitlink-cli ci +builds --page 2 --limit 10
|
||||
|
||||
# 输出为 JSON 格式
|
||||
gitlink-cli ci +builds --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--page, -p` | 否 | 页码(默认 `1`) |
|
||||
| `--limit, -l` | 否 | 每页条数(默认 `20`) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /{owner}/{repo}/builds?page=1&limit=20
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"builds": [
|
||||
{
|
||||
"id": 42,
|
||||
"build_number": 42,
|
||||
"status": "success",
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"duration": 125,
|
||||
"commit": {
|
||||
"sha": "abc123...",
|
||||
"message": "Fix bug in authentication",
|
||||
"author": "developer@example.com"
|
||||
},
|
||||
"branch": "feature/auth-fix",
|
||||
"stages": [
|
||||
{
|
||||
"stage_number": 1,
|
||||
"stage_name": "build",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"stage_number": 2,
|
||||
"stage_name": "test",
|
||||
"status": "success"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"build_number": 41,
|
||||
"status": "failed",
|
||||
"started_at": "2026-01-01T09:30:00Z",
|
||||
"duration": 45,
|
||||
"commit": {
|
||||
"sha": "def456...",
|
||||
"message": "Add new feature",
|
||||
"author": "developer@example.com"
|
||||
},
|
||||
"branch": "develop",
|
||||
"stages": [
|
||||
{
|
||||
"stage_number": 1,
|
||||
"stage_name": "build",
|
||||
"status": "failed"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total_count": 156
|
||||
},
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total_count": 156
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Resolve** owner and repo (from git remote or flags).
|
||||
2. **Execute** `gitlink-cli ci +builds`.
|
||||
3. **Display** builds in the requested format.
|
||||
|
||||
> [!NOTE]
|
||||
> This is a **Read Operation** — no confirmation needed.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **构建历史查看**:查看仓库的构建历史和状态
|
||||
- **问题排查**:查找失败的构建进行分析
|
||||
- **构建监控**:监控 CI/CD 系统的运行状态
|
||||
- **自动化脚本**:结合 JSON 格式输出进行构建分析
|
||||
|
||||
## Build Status
|
||||
|
||||
构建状态类型:
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `pending` | 等待执行 |
|
||||
| `running` | 正在执行 |
|
||||
| `success` | 构建成功 |
|
||||
| `failed` | 构建失败 |
|
||||
| `cancelled` | 构建取消 |
|
||||
| `skipped` | 构建跳过 |
|
||||
|
||||
## Data Analysis
|
||||
|
||||
使用 JSON 输出进行构建分析:
|
||||
|
||||
```bash
|
||||
# 查看最近10次构建的成功率
|
||||
gitlink-cli ci +builds --format json --limit 10 | \
|
||||
jq '[.data.builds[] | select(.status=="success")] | length / 10 * 100'
|
||||
|
||||
# 查看失败的构建
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[] | select(.status=="failed")'
|
||||
|
||||
# 查看平均构建时间
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '[.data.builds[].duration] | add / length'
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- 使用 `--format json` 可以更好地解析和分析构建数据
|
||||
- 构建列表包含详细的提交信息和分支信息
|
||||
- 支持分页,适合构建历史较多的仓库
|
||||
- 结合 `ci +logs` 可以深入分析构建失败原因
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
结合其他 CI 命令的典型工作流:
|
||||
|
||||
```bash
|
||||
# 1. 查看构建列表
|
||||
gitlink-cli ci +builds
|
||||
|
||||
# 2. 查看失败构建的日志
|
||||
gitlink-cli ci +logs --build 42
|
||||
|
||||
# 3. 重启失败的构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ci +logs](ci-logs.md) — 查看构建日志
|
||||
- [ci +restart](ci-restart.md) — 重启构建
|
||||
- [ci +stop](ci-stop.md) — 停止构建
|
||||
- [gitlink-ci](../SKILL.md) — CI/CD 操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
# ci +logs
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看指定 CI 构建的详细日志输出。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看构建日志
|
||||
gitlink-cli ci +logs --build 42
|
||||
|
||||
# 查看特定阶段的日志
|
||||
gitlink-cli ci +logs --build 42 --stage 2
|
||||
|
||||
# 查看特定步骤的日志
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 3
|
||||
|
||||
# 指定仓库查看日志
|
||||
gitlink-cli ci +logs --build 42 --owner myuser --repo myrepo
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--build, -b` | **是** | 构建编号 |
|
||||
| `--stage, -s` | 否 | 阶段编号(默认 `1`) |
|
||||
| `--step` | 否 | 步骤编号(默认 `1`) |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /{owner}/{repo}/builds/{build}/logs/{stage}/{step}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"build_number": 42,
|
||||
"stage_number": 2,
|
||||
"step_number": 1,
|
||||
"log_content": "Running tests...\nTest suite started...\n[OK] Test authentication\n[OK] Test database connection\n[FAILED] Test API endpoint\n\nTests completed: 2/3 passed",
|
||||
"stage_name": "test",
|
||||
"step_name": "run_tests",
|
||||
"timestamp": "2026-01-01T10:05:30Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the build number with the user (can use `ci +builds` to list).
|
||||
2. **Execute** `gitlink-cli ci +logs --build <number> [--stage <n>] [--step <n>]`.
|
||||
3. **Display** the log content.
|
||||
|
||||
> [!NOTE]
|
||||
> This is a **Read Operation** — no confirmation needed.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **问题排查**:查看构建失败的具体原因
|
||||
- **性能分析**:分析构建过程中的性能瓶颈
|
||||
- **调试输出**:查看代码运行时的调试信息
|
||||
- **监控执行**:实时跟踪构建执行状态
|
||||
|
||||
## CI Pipeline Structure
|
||||
|
||||
典型的 CI/CD 流水线结构:
|
||||
|
||||
```
|
||||
Stage 1: Build
|
||||
├── Step 1: Install dependencies
|
||||
├── Step 2: Build application
|
||||
└── Step 3: Run linters
|
||||
|
||||
Stage 2: Test
|
||||
├── Step 1: Run unit tests
|
||||
├── Step 2: Run integration tests
|
||||
└── Step 3: Generate coverage report
|
||||
|
||||
Stage 3: Deploy
|
||||
├── Step 1: Build deployment package
|
||||
└── Step 2: Deploy to server
|
||||
```
|
||||
|
||||
## Log Analysis
|
||||
|
||||
日志分析技巧:
|
||||
|
||||
```bash
|
||||
# 查看构建日志
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 1
|
||||
|
||||
# 结合 grep 过滤关键错误
|
||||
gitlink-cli ci +logs --build 42 --format json | \
|
||||
jq '.data.log_content' | grep "ERROR"
|
||||
|
||||
# 查看完整日志流
|
||||
gitlink-cli ci +logs --build 42 --format json | \
|
||||
jq -r '.data.log_content'
|
||||
```
|
||||
|
||||
## Stage and Step Navigation
|
||||
|
||||
查看不同阶段的日志:
|
||||
|
||||
```bash
|
||||
# Stage 1: Build stage
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
|
||||
# Stage 2: Test stage
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 1
|
||||
|
||||
# Stage 3: Deploy stage
|
||||
gitlink-cli ci +logs --build 42 --stage 3 --step 1
|
||||
```
|
||||
|
||||
## Common Log Patterns
|
||||
|
||||
常见日志模式:
|
||||
|
||||
| 模式 | 含义 |
|
||||
|------|------|
|
||||
| `[ERROR]` | 错误信息 |
|
||||
| `[FAILED]` | 测试或步骤失败 |
|
||||
| `[WARN]` | 警告信息 |
|
||||
| `[OK]` | 操作成功 |
|
||||
| `Running...` | 正在执行 |
|
||||
| `Completed` | 执行完成 |
|
||||
|
||||
## Tips
|
||||
|
||||
- 先使用 `ci +builds` 确认构建编号
|
||||
- 构建通常包含多个阶段,需要指定正确的阶段编号
|
||||
- 日志内容可能很长,建议使用 `--format json` 便于解析
|
||||
- 结合构建状态可以快速定位问题
|
||||
|
||||
## Troubleshooting Workflow
|
||||
|
||||
典型的故障排查工作流:
|
||||
|
||||
```bash
|
||||
# 1. 查看构建列表,找到失败的构建
|
||||
gitlink-cli ci +builds | grep "failed"
|
||||
|
||||
# 2. 查看失败构建的详细状态
|
||||
gitlink-cli ci +builds --build 42 --format json | \
|
||||
jq '.data.builds[] | .stages[]'
|
||||
|
||||
# 3. 查看失败阶段的日志
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 1
|
||||
|
||||
# 4. 根据日志信息修复问题
|
||||
|
||||
# 5. 重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 构建不存在 | 检查构建编号是否正确 |
|
||||
| `404` | 阶段或步骤不存在 | 确认阶段和步骤编号 |
|
||||
| `403` | 权限不足 | 确认有查看该仓库构建的权限 |
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
高级用法示例:
|
||||
|
||||
```bash
|
||||
# 导出构建日志到文件
|
||||
gitlink-cli ci +logs --build 42 --format json | \
|
||||
jq -r '.data.log_content' > build_42_logs.txt
|
||||
|
||||
# 分析日志中的错误模式
|
||||
gitlink-cli ci +logs --build 42 --format json | \
|
||||
jq -r '.data.log_content' | grep -c "ERROR"
|
||||
|
||||
# 查看所有阶段的日志(循环)
|
||||
for stage in {1..3}; do
|
||||
echo "=== Stage $stage ==="
|
||||
gitlink-cli ci +logs --build 42 --stage $stage --step 1
|
||||
done
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ci +builds](ci-list.md) — 查看构建列表
|
||||
- [ci +restart](ci-restart.md) — 重启构建
|
||||
- [gitlink-ci](../SKILL.md) — CI/CD 操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# ci +restart
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
重新启动失败的或取消的 CI 构建。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
|
||||
# 指定仓库重启构建
|
||||
gitlink-cli ci +restart --build 42 --owner myuser --repo myrepo
|
||||
|
||||
# 重启失败的构建(JSON 输出)
|
||||
gitlink-cli ci +restart --build 42 --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--build, -b` | **是** | 构建编号 |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /{owner}/{repo}/builds/{build}/restart
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"old_build_number": 42,
|
||||
"new_build_number": 43,
|
||||
"status": "pending",
|
||||
"message": "Build restarted successfully",
|
||||
"triggered_at": "2026-01-01T11:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the build number to restart with the user.
|
||||
2. **Check** the current build status (optional, using `ci +builds`).
|
||||
3. **Execute** `gitlink-cli ci +restart --build <number>`.
|
||||
4. **Report** the restart result and new build number.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent before executing.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **失败重试**:构建因临时问题失败后重试
|
||||
- **取消后重新执行**:构建被取消后需要重新执行
|
||||
- **代码修复后验证**:修复代码后重新验证构建
|
||||
- **环境问题恢复**:CI 环境问题恢复后重新构建
|
||||
|
||||
## When to Restart
|
||||
|
||||
**适合重启的情况:**
|
||||
- ✅ 构建因临时网络问题失败
|
||||
- ✅ 依赖服务暂时不可用
|
||||
- ✅ 代码修复后需要重新验证
|
||||
- ✅ CI 环境问题已解决
|
||||
|
||||
**不适合重启的情况:**
|
||||
- ❌ 代码存在严重错误
|
||||
- ❌ 测试用例本身有问题
|
||||
- ❌ 构建配置需要修改
|
||||
- ❌ 依赖库版本不兼容
|
||||
|
||||
## Restart Behavior
|
||||
|
||||
重启构建的行为特点:
|
||||
|
||||
| 方面 | 说明 |
|
||||
|------|------|
|
||||
| **新构建编号** | 重启会创建新的构建编号 |
|
||||
| **相同代码** | 使用相同的提交代码 |
|
||||
| **相同环境** | 使用相同的构建环境 |
|
||||
| **独立日志** | 新构建有独立的日志记录 |
|
||||
| **状态继承** | 不会继承原构建的状态 |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **查看日志**:重启前先查看失败原因
|
||||
2. **修复问题**:如果是代码问题,先修复再重启
|
||||
3. **监控新构建**:重启后监控新构建的执行状态
|
||||
4. **资源考虑**:频繁重启会消耗 CI 资源
|
||||
|
||||
## Troubleshooting Workflow
|
||||
|
||||
典型的故障排查和重启流程:
|
||||
|
||||
```bash
|
||||
# 1. 查看构建列表,找到失败的构建
|
||||
gitlink-cli ci +builds | grep "failed"
|
||||
|
||||
# 2. 查看失败构建的详细状态
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[] | select(.build_number==42)'
|
||||
|
||||
# 3. 查看失败阶段的日志
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 1
|
||||
|
||||
# 4. 分析日志,确定失败原因
|
||||
|
||||
# 5. 如果是临时问题,重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
|
||||
# 6. 如果是代码问题,修复后重启
|
||||
# (先修复代码,然后)
|
||||
gitlink-cli ci +restart --build 42
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 构建不存在 | 检查构建编号是否正确 |
|
||||
| `400` | 构建正在运行 | 正在运行的构建无法重启 |
|
||||
| `403` | 权限不足 | 确认有操作该仓库构建的权限 |
|
||||
| `429` | 重启次数过多 | 短时间内重启次数过多,等待后重试 |
|
||||
|
||||
## Pre-Restart Checklist
|
||||
|
||||
重启前检查清单:
|
||||
|
||||
- [ ] 确认构建编号正确
|
||||
- [ ] 查看失败日志,了解失败原因
|
||||
- [ ] 确认问题已解决(如果是代码问题)
|
||||
- [ ] 检查 CI 系统状态
|
||||
- [ ] 确认有足够的 CI 资源
|
||||
- [ ] 考虑是否需要修改构建配置
|
||||
|
||||
## Post-Restart Actions
|
||||
|
||||
重启后的后续操作:
|
||||
|
||||
```bash
|
||||
# 1. 重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
|
||||
# 2. 获取新构建编号
|
||||
gitlink-cli ci +restart --build 42 --format json | \
|
||||
jq '.data.new_build_number'
|
||||
|
||||
# 3. 监控新构建状态
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[0]'
|
||||
|
||||
# 4. 查看新构建的日志(如需要)
|
||||
gitlink-cli ci +logs --build 43 --stage 1 --step 1
|
||||
```
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
团队协作时的建议:
|
||||
|
||||
1. **沟通确认**:重启构建前通知相关团队成员
|
||||
2. **记录原因**:记录重启的原因和时间
|
||||
3. **状态更新**:及时更新构建状态给团队
|
||||
4. **结果分享**:重启完成后分享结果
|
||||
|
||||
## Tips
|
||||
|
||||
- 重启会创建新的构建编号,原构建历史仍保留
|
||||
- 重启前建议先查看日志,确认问题性质
|
||||
- 对于重复失败的情况,建议先修复根本原因
|
||||
- 可以通过 `ci +builds` 查看重启后的新构建状态
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
使用注意事项:
|
||||
|
||||
- ⚠️ **资源消耗**:每次重启都会消耗 CI 资源
|
||||
- ⚠️ **时间成本**:重新执行完整的构建流程
|
||||
- ⚠️ **排队时间**:新构建可能需要排队等待
|
||||
- ⚠️ **频繁重启**:避免无意义的频繁重启
|
||||
|
||||
## References
|
||||
|
||||
- [ci +builds](ci-list.md) — 查看构建列表
|
||||
- [ci +logs](ci-logs.md) — 查看构建日志
|
||||
- [ci +stop](ci-stop.md) — 停止构建
|
||||
- [gitlink-ci](../SKILL.md) — CI/CD 操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
# ci +stop
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
停止正在运行的 CI 构建。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 停止构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
|
||||
# 指定仓库停止构建
|
||||
gitlink-cli ci +stop --build 42 --owner myuser --repo myrepo
|
||||
|
||||
# 停止构建(JSON 输出)
|
||||
gitlink-cli ci +stop --build 42 --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--build, -b` | **是** | 构建编号 |
|
||||
| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 否* | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 否 | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
DELETE /{owner}/{repo}/builds/{build}/stop
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"build_number": 42,
|
||||
"status": "cancelled",
|
||||
"message": "Build stopped successfully",
|
||||
"stopped_at": "2026-01-01T11:30:00Z",
|
||||
"duration": 180
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Confirm** the build number to stop with the user.
|
||||
2. **Check** the current build status (ensure it's running).
|
||||
3. **Execute** `gitlink-cli ci +stop --build <number>`.
|
||||
4. **Report** the stop result.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent before executing. This will terminate a running build.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **错误停止**:构建出现错误需要立即停止
|
||||
- **资源释放**:释放 CI 资源给其他构建
|
||||
- **配置错误**:构建配置错误需要停止
|
||||
- **测试中止**:测试过程中发现问题需要中止
|
||||
- **时间限制**:构建时间过长需要停止
|
||||
|
||||
## When to Stop
|
||||
|
||||
**适合停止的情况:**
|
||||
- ✅ 构建明显出现错误,继续执行无意义
|
||||
- ✅ 发现严重bug,需要立即停止
|
||||
- ✅ 构建配置错误,需要修改后重新执行
|
||||
- ✅ 误触发构建,需要立即取消
|
||||
- ✅ 构建时间过长,超出预期
|
||||
|
||||
**不适合停止的情况:**
|
||||
- ❌ 构建接近完成
|
||||
- ❌ 仅为节省时间而停止正常构建
|
||||
- ❌ 不确定构建是否有问题
|
||||
|
||||
## Stop Behavior
|
||||
|
||||
停止构建的行为特点:
|
||||
|
||||
| 方面 | 说明 |
|
||||
|------|------|
|
||||
| **立即停止** | 通常会立即中断构建执行 |
|
||||
| **状态变更** | 构建状态变为 `cancelled` |
|
||||
| **资源释放** | 释放 CI 计算资源 |
|
||||
| **日志保留** | 已执行的日志会保留 |
|
||||
| **不可恢复** | 停止的构建无法恢复执行 |
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
停止构建前考虑:
|
||||
|
||||
- ⚠️ **进度损失**:已执行的进度会丢失
|
||||
- ⚠️ **资源浪费**:已消耗的资源无法回收
|
||||
- ⚠️ **团队影响**:可能影响其他依赖此构建的任务
|
||||
- ⚠️ **重新执行**:需要重新启动完整的构建
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **确认状态**:停止前确认构建确实在运行
|
||||
2. **评估影响**:考虑停止对其他流程的影响
|
||||
3. **记录原因**:记录停止构建的原因
|
||||
4. **后续处理**:计划停止后的后续操作
|
||||
|
||||
## Stop Workflow
|
||||
|
||||
典型的停止构建工作流:
|
||||
|
||||
```bash
|
||||
# 1. 查看运行中的构建
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[] | select(.status=="running")'
|
||||
|
||||
# 2. 确认要停止的构建编号
|
||||
gitlink-cli ci +builds | grep "running"
|
||||
|
||||
# 3. 停止构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
|
||||
# 4. 验证停止状态
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[] | select(.build_number==42) | .status'
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
常见错误及解决方案:
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `404` | 构建不存在 | 检查构建编号是否正确 |
|
||||
| `400` | 构建已完成 | 构建已经完成或停止,无法再停止 |
|
||||
| `403` | 权限不足 | 确认有操作该仓库构建的权限 |
|
||||
| `409` | 构建已完成 | 构建已经自然结束 |
|
||||
|
||||
## Pre-Stop Checklist
|
||||
|
||||
停止前检查清单:
|
||||
|
||||
- [ ] 确认构建编号正确
|
||||
- [ ] 确认构建正在运行
|
||||
- [ ] 评估停止的影响范围
|
||||
- [ ] 确认停止原因合理
|
||||
- [ ] 考虑后续处理方案
|
||||
- [ ] 通知相关团队成员
|
||||
|
||||
## Post-Stop Actions
|
||||
|
||||
停止后的后续操作:
|
||||
|
||||
```bash
|
||||
# 1. 停止构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
|
||||
# 2. 查看停止状态
|
||||
gitlink-cli ci +builds --format json | \
|
||||
jq '.data.builds[] | select(.build_number==42)'
|
||||
|
||||
# 3. 查看已执行的日志
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
|
||||
# 4. 根据需要重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
常见使用场景:
|
||||
|
||||
### 场景1:发现严重错误
|
||||
```bash
|
||||
# 查看运行中的构建
|
||||
gitlink-cli ci +builds | grep "running"
|
||||
|
||||
# 查看日志发现严重错误
|
||||
gitlink-cli ci +logs --build 42 --stage 2 --step 1
|
||||
|
||||
# 立即停止构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
```
|
||||
|
||||
### 场景2:误触发构建
|
||||
```bash
|
||||
# 发现误触发了构建
|
||||
gitlink-cli ci +builds | grep "running"
|
||||
|
||||
# 立即停止误触发的构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
```
|
||||
|
||||
### 场景3:配置错误
|
||||
```bash
|
||||
# 发现构建配置错误
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
|
||||
# 停止当前构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
|
||||
# 修复配置后重新构建
|
||||
# (修复配置)
|
||||
gitlink-cli ci +restart --build 42
|
||||
```
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
团队协作时的建议:
|
||||
|
||||
1. **及时通知**:停止构建前通知相关团队成员
|
||||
2. **说明原因**:向团队解释为什么需要停止构建
|
||||
3. **状态同步**:更新项目管理系统中的构建状态
|
||||
4. **后续计划**:告知团队停止后的处理计划
|
||||
|
||||
## Tips
|
||||
|
||||
- 停止前建议先确认构建状态,避免重复操作
|
||||
- 查看构建日志可以帮助判断是否值得停止
|
||||
- 停止后可以考虑是否需要重启或修复后重新构建
|
||||
- 对于长时间运行的构建,定期检查状态可能更合适
|
||||
|
||||
## Alternatives
|
||||
|
||||
替代方案考虑:
|
||||
|
||||
| 情况 | 停止 | 等待完成 | 其他方案 |
|
||||
|------|------|----------|----------|
|
||||
| 严重错误 | ✅ 推荐 | ❌ 不推荐 | 修复后重启 |
|
||||
| 临时问题 | ⚠️ 可选 | ✅ 推荐 | 等待自动恢复 |
|
||||
| 配置错误 | ✅ 推荐 | ❌ 不推荐 | 修复配置后重启 |
|
||||
| 时间过长 | ⚠️ 可选 | ✅ 推荐 | 优化构建流程 |
|
||||
|
||||
## References
|
||||
|
||||
- [ci +builds](ci-list.md) — 查看构建列表
|
||||
- [ci +logs](ci-logs.md) — 查看构建日志
|
||||
- [ci +restart](ci-restart.md) — 重启构建
|
||||
- [gitlink-ci](../SKILL.md) — CI/CD 操作总览
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
---
|
||||
name: gitlink-code-insight
|
||||
version: 4.0.0
|
||||
description: "功能全景仪表盘:当用户想了解 gitlink-cli 有哪些功能时,生成交互式 HTML 页面并打开浏览器展示。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["python3"]
|
||||
---
|
||||
|
||||
# gitlink-code-insight(功能全景仪表盘)
|
||||
|
||||
## 触发条件
|
||||
|
||||
用户问以下问题时触发:
|
||||
- "gitlink-cli 有哪些功能"
|
||||
- "帮我生成一个功能展示页面"
|
||||
- "我想浏览所有可用命令"
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 根据下方 Shortcuts 数据生成单文件 HTML
|
||||
2. 写入 `doc/dashboard.html`
|
||||
3. 用 `python3 -c "import webbrowser; webbrowser.open('file://$(pwd)/doc/dashboard.html')"` 打开
|
||||
|
||||
## 页面要求
|
||||
|
||||
- 暗色主题(GitHub Dark 风格)
|
||||
- 顶部:标题 + 统计数字(分类数、Shortcuts 总数)+ 搜索框
|
||||
- 主体:分类卡片列表,每个卡片可折叠展开
|
||||
- 卡片标题:图标 + 分类名 + 命令计数
|
||||
- 展开后显示该分类下所有 shortcut 行
|
||||
- 每行:命令(等宽蓝色)+ 认证标签(绿色=需认证,蓝色=公开)+ 描述
|
||||
- 点击 shortcut 行展开代码示例
|
||||
- 搜索框实时过滤(按命令名和描述匹配),无匹配时隐藏整个分类
|
||||
- 纯 CSS + 原生 JS,无外部依赖
|
||||
|
||||
---
|
||||
|
||||
## Shortcuts 数据
|
||||
|
||||
### 一、仓库管理 📦
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `repo +list` | 仓库列表 | 否 | `gitlink-cli repo +list --user zhangsan` |
|
||||
| `repo +info` | 仓库详情 | 否 | `gitlink-cli repo +info --owner Gitlink --repo forgeplus` |
|
||||
| `repo +create` | 创建仓库 | 是 | `gitlink-cli repo +create --name my-project --description "项目描述"` |
|
||||
| `repo +fork` | Fork 仓库 | 是 | `gitlink-cli repo +fork --owner Gitlink --repo forgeplus` |
|
||||
| `repo +delete` | 删除仓库(不可逆) | 是 | `gitlink-cli repo +delete --owner myuser --repo old-project` |
|
||||
| `repo +batch-create` | 批量创建仓库 | 是 | `gitlink-cli repo +batch-create --from repos.csv` |
|
||||
| `repo +batch-update` | 批量更新仓库 | 是 | `gitlink-cli repo +batch-update --from updates.csv` |
|
||||
| `repo +add-member` | 添加仓库成员 | 是 | `gitlink-cli repo +add-member --owner myuser --repo myrepo --user newmember --role developer` |
|
||||
|
||||
### 二、分支管理 🌿
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `branch +list` | 分支列表 | 否 | `gitlink-cli branch +list --owner Gitlink --repo forgeplus` |
|
||||
| `branch +create` | 创建分支 | 是 | `gitlink-cli branch +create --name feature/new-feature` |
|
||||
| `branch +delete` | 删除分支(不可逆) | 是 | `gitlink-cli branch +delete --name feature/old-feature` |
|
||||
| `branch +protect` | 保护分支 | 是 | `gitlink-cli branch +protect --name main` |
|
||||
| `branch +unprotect` | 取消保护 | 是 | `gitlink-cli branch +unprotect --name main` |
|
||||
|
||||
### 三、Issue 管理 🐛
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `issue +list` | Issue 列表 | 否 | `gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open` |
|
||||
| `issue +view` | Issue 详情 | 否 | `gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4` |
|
||||
| `issue +create` | 创建 Issue | 是 | `gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤"` |
|
||||
| `issue +update` | 更新 Issue | 是 | `gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"` |
|
||||
| `issue +close` | 关闭 Issue | 是 | `gitlink-cli issue +close --number 4` |
|
||||
| `issue +batch-close` | 批量关闭 Issue | 是 | `gitlink-cli issue +batch-close --numbers 123,124 --dry-run` |
|
||||
| `issue +comment` | 添加评论 | 是 | `gitlink-cli issue +comment --number 4 --body "已修复"` |
|
||||
|
||||
### 四、Pull Request 🔀
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `pr +list` | PR 列表 | 否 | `gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open` |
|
||||
| `pr +view` | PR 详情 | 否 | `gitlink-cli pr +view --id 3` |
|
||||
| `pr +create` | 创建 PR | 是 | `gitlink-cli pr +create --title "feat: 新功能" --head feature/x --base master` |
|
||||
| `pr +merge` | 合并 PR | 是 | `gitlink-cli pr +merge --id 3 --method squash` |
|
||||
| `pr +close` | 关闭 PR | 是 | `gitlink-cli pr +close --id 3` |
|
||||
| `pr +files` | 变更文件列表 | 否 | `gitlink-cli pr +files --id 3` |
|
||||
| `pr +diff` | 查看提交列表 | 否 | `gitlink-cli pr +diff --id 3` |
|
||||
| `pr +comment` | PR 评论 | 是 | `gitlink-cli pr +comment --id 3 --body "LGTM"` |
|
||||
| `pr +review` | 代码审查 | 是 | `gitlink-cli pr +review --id 3 --event COMMENT --body "整体 LGTM"` |
|
||||
|
||||
### 五、版本发布 🚀
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `release +list` | 发布列表 | 否 | `gitlink-cli release +list --owner Gitlink --repo forgeplus` |
|
||||
| `release +view` | 发布详情 | 否 | `gitlink-cli release +view --id <version_id>` |
|
||||
| `release +create` | 创建发布 | 是 | `gitlink-cli release +create --tag v1.0.0 --name "v1.0.0" --target master` |
|
||||
| `release +delete` | 删除发布(不可逆) | 是 | `gitlink-cli release +delete --id <version_id>` |
|
||||
|
||||
### 六、Wiki 管理 📖
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `wiki +list` | Wiki 页面列表 | 否 | `gitlink-cli wiki +list --owner Gitlink --repo forgeplus` |
|
||||
| `wiki +view` | 查看页面内容 | 否 | `gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home"` |
|
||||
| `wiki +create` | 创建页面 | 是 | `gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md` |
|
||||
| `wiki +update` | 更新页面 | 是 | `gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "新内容"` |
|
||||
| `wiki +delete` | 删除页面 | 是 | `gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面"` |
|
||||
|
||||
### 七、CI/CD ⚙️
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `ci +builds` | 构建列表 | 是 | `gitlink-cli ci +builds --owner myuser --repo myrepo` |
|
||||
| `ci +logs` | 构建日志 | 是 | `gitlink-cli ci +logs --build 42 --stage 1 --step 1` |
|
||||
| `ci +restart` | 重启构建 | 是 | `gitlink-cli ci +restart --build 42` |
|
||||
| `ci +stop` | 停止构建 | 是 | `gitlink-cli ci +stop --build 42` |
|
||||
|
||||
### 八、Webhook 🔔
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `webhook +list` | Webhook 列表 | 是 | `gitlink-cli webhook +list --owner myuser --repo myrepo` |
|
||||
| `webhook +info` | Webhook 详情 | 是 | `gitlink-cli webhook +info --owner myuser --repo myrepo --id 123` |
|
||||
| `webhook +events` | 支持的事件类型 | 否 | `gitlink-cli webhook +events` |
|
||||
| `webhook +create` | 创建 Webhook | 是 | `gitlink-cli webhook +create --url https://example.com/hook --events push` |
|
||||
| `webhook +update` | 更新 Webhook | 是 | `gitlink-cli webhook +update --id 123 --events push,pull_request` |
|
||||
| `webhook +test` | 测试 Webhook | 是 | `gitlink-cli webhook +test --id 123 --event push` |
|
||||
| `webhook +delete` | 删除 Webhook | 是 | `gitlink-cli webhook +delete --id 123` |
|
||||
|
||||
### 九、组织管理 🏢
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `org +list` | 组织列表 | 否 | `gitlink-cli org +list` |
|
||||
| `org +info` | 组织详情 | 否 | `gitlink-cli org +info --id Gitlink` |
|
||||
| `org +members` | 成员列表 | 否 | `gitlink-cli org +members --id Gitlink` |
|
||||
| `org +create` | 创建组织 | 是 | `gitlink-cli org +create --name my-org --description "我的组织"` |
|
||||
| `org +batch-add` | 批量添加成员 | 是 | `gitlink-cli org +batch-add --id my-org --users "user1,user2"` |
|
||||
|
||||
### 十、用户与搜索 👤
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `user +me` | 当前登录用户 | 是 | `gitlink-cli user +me` |
|
||||
| `user +info` | 用户详情 | 否 | `gitlink-cli user +info --login zhangsan` |
|
||||
| `search +repos` | 搜索仓库 | 否 | `gitlink-cli search +repos --keyword "machine learning"` |
|
||||
| `search +users` | 搜索用户 | 否 | `gitlink-cli search +users --keyword "zhangsan"` |
|
||||
|
||||
### 十一、安全与合规 🛡️
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `compliance +scan` | 全量扫描 | 否 | `gitlink-cli compliance +scan` |
|
||||
| `compliance +license` | 许可证合规检查 | 否 | `gitlink-cli compliance +license` |
|
||||
| `compliance +deps` | 依赖许可证检查 | 否 | `gitlink-cli compliance +deps` |
|
||||
| `compliance +secrets` | 敏感信息扫描 | 否 | `gitlink-cli compliance +secrets` |
|
||||
| `compliance +exposure` | PII 与暴露面扫描 | 否 | `gitlink-cli compliance +exposure` |
|
||||
| `compliance +vocab` | 敏感词汇扫描 | 否 | `gitlink-cli compliance +vocab` |
|
||||
|
||||
### 十二、新人引导 👋
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `onboard +welcome` | 添加引导评论 | 是 | `gitlink-cli onboard +welcome --issues "3,7,15"` |
|
||||
|
||||
### 十三、团队管理 👥
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `team +list` | 团队列表 | 否 | `gitlink-cli team +list --org my-org` |
|
||||
| `team +create` | 创建团队 | 是 | `gitlink-cli team +create --org my-org --name dev-team` |
|
||||
| `team +add-member` | 添加成员 | 是 | `gitlink-cli team +add-member --org my-org --team dev-team --user newmember` |
|
||||
|
||||
### 十四、贡献报告 📊
|
||||
|
||||
| 命令 | 描述 | 认证 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `contrib +report` | 贡献统计报告 | 否 | `gitlink-cli contrib +report --owner myuser --repo myrepo` |
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
# gitlink-code-review - 智能代码审查 Skill
|
||||
|
||||
[](https://www.gitlink.org.cn/zzx-coder/gitlink-cli)
|
||||
[](SKILL.md)
|
||||
[](SKILL.md)
|
||||
|
||||
欢迎使用 **gitlink-code-review** Skill!这是一个 AI 驱动的自动化代码审查工具,帮助开发者和 Reviewers 快速分析 GitLink PR 的代码质量。
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
### 核心功能
|
||||
|
||||
- ✅ **自动代码分析**:获取 PR 的文件列表和 diff 内容
|
||||
- ✅ **多维度审查**:代码质量、安全性、性能、可维护性
|
||||
- ✅ **结构化报告**:生成 JSON/Markdown 格式的审查报告
|
||||
- ✅ **智能建议**:提供具体的代码修改建议
|
||||
- ✅ **自动评论**:将审查意见自动添加为 PR 评论
|
||||
- ✅ **AI 驱动**:基于 Claude 的代码理解能力
|
||||
|
||||
### 审查维度
|
||||
|
||||
| 维度 | 检查项 | 说明 |
|
||||
|------|--------|------|
|
||||
| **代码质量** | 复杂度、命名规范、注释完整性 | 确保代码清晰易读 |
|
||||
| **安全性** | SQL 注入、XSS、敏感信息泄露 | 发现安全漏洞 |
|
||||
| **性能** | 资源泄漏、循环效率、数据库查询 | 优化性能问题 |
|
||||
| **可维护性** | 代码重复、职责单一、测试覆盖 | 提高代码可维护性 |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. **安装 gitlink-cli**
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
2. **配置认证**
|
||||
```bash
|
||||
gitlink-cli auth login
|
||||
```
|
||||
|
||||
3. **验证安装**
|
||||
```bash
|
||||
gitlink-cli pr +list
|
||||
```
|
||||
|
||||
### 基础使用
|
||||
|
||||
#### 1. 获取 PR 信息
|
||||
|
||||
```bash
|
||||
# 查看 PR 详情
|
||||
gitlink-cli pr +view --id 123 --format json
|
||||
|
||||
# 获取变更文件列表
|
||||
gitlink-cli pr +files --id 123 --format json
|
||||
|
||||
# 获取 diff 内容
|
||||
gitlink-cli pr +diff --id 123 --format json
|
||||
```
|
||||
|
||||
#### 2. 进行代码审查
|
||||
|
||||
**AI Agent 方式**(推荐):
|
||||
|
||||
```
|
||||
用户: "帮我审查 PR #123,检查代码质量、安全性和性能问题"
|
||||
|
||||
AI Agent 将:
|
||||
1. 获取 PR 的代码变更
|
||||
2. 分析代码质量和潜在问题
|
||||
3. 生成结构化的审查报告
|
||||
4. (可选)自动添加审查评论
|
||||
```
|
||||
|
||||
**手动方式**:
|
||||
|
||||
```bash
|
||||
# 获取 diff 并分析
|
||||
gitlink-cli pr +diff --id 123 --format json > pr_diff.json
|
||||
|
||||
# 使用 AI 工具分析 pr_diff.json
|
||||
# 生成审查报告
|
||||
|
||||
# (可选)添加评论到 PR
|
||||
gitlink-cli api POST /:owner/:repo/pulls/123/reviews --body '{
|
||||
"body": "审查报告内容...",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
```
|
||||
|
||||
### 完整工作流示例
|
||||
|
||||
详见 [`examples/comprehensive-review-workflow.md`](examples/comprehensive-review-workflow.md)
|
||||
|
||||
## 📊 审查报告示例
|
||||
|
||||
### 简化版报告
|
||||
|
||||
```markdown
|
||||
# 代码审查报告
|
||||
|
||||
## 总体评分: 85/100 ⭐⭐⭐⭐
|
||||
|
||||
## 🔴 高优先级问题(2)
|
||||
|
||||
1. **敏感信息泄露** - `src/auth/login.go:45`
|
||||
- 硬编码的密钥不应出现在代码中
|
||||
- 建议:使用环境变量存储密钥
|
||||
|
||||
2. **资源泄漏** - `src/auth/login.go:78`
|
||||
- 数据库连接未关闭
|
||||
- 建议:使用 defer 确保连接关闭
|
||||
|
||||
## ⭐ 优秀实践(1)
|
||||
|
||||
1. **优秀的错误处理** - `src/auth/user.go:120`
|
||||
```
|
||||
|
||||
### 完整版报告
|
||||
|
||||
完整版报告包含:
|
||||
- PR 基本信息
|
||||
- 各维度详细评分
|
||||
- 按优先级排序的问题列表
|
||||
- 具体的代码位置和修改建议
|
||||
- 优秀实践和改进建议
|
||||
- 逐文件的详细分析
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1:开发者自审
|
||||
|
||||
开发者在提交 PR 前进行自审:
|
||||
```bash
|
||||
# 获取 PR diff
|
||||
gitlink-cli pr +diff --id 123 --format json
|
||||
|
||||
# AI 分析并生成报告
|
||||
# 修复发现的问题
|
||||
```
|
||||
|
||||
### 场景 2:Reviewers 辅助审查
|
||||
|
||||
Reviewers 使用 AI 辅助审查:
|
||||
```bash
|
||||
# 快速获取审查报告
|
||||
gitlink-cli pr +view --id 123 --format json
|
||||
gitlink-cli pr +diff --id 123 --format json
|
||||
|
||||
# AI 生成报告,Reviewers 参考
|
||||
# 专注于业务逻辑和架构设计
|
||||
```
|
||||
|
||||
### 场景 3:CI/CD 集成
|
||||
|
||||
在 CI/CD 流程中自动审查:
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
code_review:
|
||||
script:
|
||||
- gitlink-cli pr +diff --id $MR_ID --format json
|
||||
- ai-code-review --input pr_diff.json --output report.json
|
||||
- check-score --min 70 report.json
|
||||
```
|
||||
|
||||
### 场景 4:新贡献者指导
|
||||
|
||||
为新贡献者的 PR 提供详细指导:
|
||||
```bash
|
||||
# 全面审查新贡献者的 PR
|
||||
# 提供详细的代码指导
|
||||
# 帮助改进代码质量
|
||||
```
|
||||
|
||||
## 📚 文档导航
|
||||
|
||||
- **[SKILL.md](SKILL.md)** - 技能总览和完整功能说明
|
||||
- **[REFERENCE.md](REFERENCE.md)** - API 详细参考
|
||||
- **[references/](references/)** - 详细操作指南
|
||||
- **[examples/](examples/)** - 工作流示例
|
||||
|
||||
### 快速查找
|
||||
|
||||
- **我想了解基本用法**: [SKILL.md](SKILL.md#使用方式)
|
||||
- **我想看审查维度**: [SKILL.md](SKILL.md#审查维度)
|
||||
- **我想看工作流示例**: [examples/comprehensive-review-workflow.md](examples/comprehensive-review-workflow.md)
|
||||
- **我想了解 API 细节**: [REFERENCE.md](REFERENCE.md)
|
||||
|
||||
## 🤖 AI Agent 使用
|
||||
|
||||
Claude Code 和其他 AI Agent 可以直接使用此 Skill:
|
||||
|
||||
```
|
||||
用户请求: "审查 PR #123"
|
||||
↓
|
||||
AI 读取 gitlink-code-review/SKILL.md
|
||||
↓
|
||||
AI 执行:
|
||||
1. gitlink-cli pr +view --id 123 --format json
|
||||
2. gitlink-cli pr +files --id 123 --format json
|
||||
3. gitlink-cli pr +diff --id 123 --format json
|
||||
↓
|
||||
AI 分析代码变更
|
||||
↓
|
||||
AI 生成结构化审查报告
|
||||
↓
|
||||
AI (可选)添加评论到 PR
|
||||
↓
|
||||
完成!
|
||||
```
|
||||
|
||||
### 典型 AI 对话
|
||||
|
||||
**用户**: "帮我审查一下 PR #123,重点关注安全性问题"
|
||||
|
||||
**AI Agent**:
|
||||
```
|
||||
我来帮您审查 PR #123,重点关注安全性问题。
|
||||
|
||||
[获取 PR 信息...]
|
||||
[分析代码变更...]
|
||||
|
||||
发现以下安全问题:
|
||||
|
||||
🔴 高优先级(1):
|
||||
1. SQL 注入风险 - src/auth/login.go:45
|
||||
- 直接拼接用户输入到 SQL 语句
|
||||
- 建议:使用参数化查询
|
||||
|
||||
⚠️ 中优先级(1):
|
||||
1. 缺少输入验证 - src/auth/login.go:30
|
||||
- 未验证用户名长度和格式
|
||||
- 建议:添加输入验证中间件
|
||||
|
||||
总体评分:70/100
|
||||
安全性评分:60/100 ⚠️
|
||||
|
||||
需要我详细说明修复方法吗?
|
||||
```
|
||||
|
||||
## 🎓 最佳实践
|
||||
|
||||
### 审查时机
|
||||
|
||||
1. **PR 创建后**:立即进行初步审查
|
||||
2. **PR 更新后**:审查新增的代码变更
|
||||
3. **合并前**:最终审查确认代码质量
|
||||
|
||||
### 审查重点
|
||||
|
||||
根据 PR 类型调整审查重点:
|
||||
- **功能 PR**:代码质量 + 可维护性
|
||||
- **Bug 修复**:修复完整性 + 测试覆盖
|
||||
- **重构 PR**:性能改进 + 代码简化
|
||||
- **文档 PR**:文档完整性 + 准确性
|
||||
|
||||
### 评论规范
|
||||
|
||||
- ✅ **建设性**:提供具体的修改建议
|
||||
- ✅ **礼貌友好**:使用积极的语言
|
||||
- ✅ **解释原因**:说明为什么需要修改
|
||||
- ✅ **认可优点**:指出优秀实践
|
||||
|
||||
### 自动化审查
|
||||
|
||||
配置 CI/CD 自动审查:
|
||||
```yaml
|
||||
# 合并门禁示例
|
||||
if (review_score < 70) {
|
||||
block_merge("代码审查评分低于 70 分")
|
||||
}
|
||||
if (high_priority_issues > 0) {
|
||||
block_merge("存在高优先级问题")
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 质量标准
|
||||
|
||||
### 审查评分体系
|
||||
|
||||
| 分数范围 | 等级 | 说明 |
|
||||
|---------|------|------|
|
||||
| 90-100 | ⭐⭐⭐⭐⭐ 优秀 | 代码质量高,可以直接合并 |
|
||||
| 75-89 | ⭐⭐⭐⭐ 良好 | 代码质量良好,小幅改进后可合并 |
|
||||
| 60-74 | ⭐⭐⭐ 一般 | 存在一些问题,建议改进后合并 |
|
||||
| < 60 | ⭐⭐ 较差 | 存在严重问题,必须修复 |
|
||||
|
||||
### 问题优先级
|
||||
|
||||
| 优先级 | 图标 | 说明 | 是否阻止合并 |
|
||||
|--------|------|------|--------------|
|
||||
| HIGH | 🔴 | 安全漏洞、严重性能问题 | 是 |
|
||||
| MEDIUM | ⚠️ | 代码质量问题、潜在风险 | 建议 |
|
||||
| LOW | ℹ️ | 代码风格、轻微改进 | 否 |
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 如何提高审查准确性?
|
||||
|
||||
**A**:
|
||||
1. 提供完整的 diff 内容
|
||||
2. 根据项目类型调整审查规则
|
||||
3. 结合项目上下文分析
|
||||
4. 定期更新审查规则
|
||||
|
||||
### Q: 如何处理误报?
|
||||
|
||||
**A**:
|
||||
1. AI 审查可能产生误报,需要人工验证
|
||||
2. 可以配置白名单忽略特定规则
|
||||
3. 提供反馈改进审查规则
|
||||
|
||||
### Q: 审查报告可以作为合并条件吗?
|
||||
|
||||
**A**:
|
||||
1. 可以将审查评分设置为合并门禁
|
||||
2. 建议设置最低评分(如 70 分)
|
||||
3. 高优先级问题必须修复后才能合并
|
||||
|
||||
### Q: 如何集成到 CI/CD?
|
||||
|
||||
**A**:
|
||||
参考 [`examples/ci-integration.md`](examples/ci-integration.md) 中的配置示例
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
- [gitlink-cli 主项目](https://www.gitlink.org.cn/zzx-coder/gitlink-cli)
|
||||
- [gitlink-pr Skill](../gitlink-pr/SKILL.md) - PR 操作指南
|
||||
- [gitlink-workflow Skill](../gitlink-workflow/SKILL.md) - AI 工作流
|
||||
- [代码审查最佳实践](https://google.github.io/eng-practices/review/)
|
||||
|
||||
## 📈 更新日志
|
||||
|
||||
### v1.0.0 (2026-06-12)
|
||||
|
||||
- ✅ 初始版本发布
|
||||
- ✅ 支持代码质量、安全性、性能、可维护性审查
|
||||
- ✅ 生成结构化审查报告
|
||||
- ✅ AI Agent 集成
|
||||
- ✅ 完整文档和示例
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎贡献!如果你有改进建议或发现问题,请:
|
||||
|
||||
1. 创建 Issue 描述问题或建议
|
||||
2. 提交 Pull Request 改进 Skill
|
||||
3. 分享你的使用经验
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
- **查看文档**: [SKILL.md](SKILL.md)
|
||||
- **查看示例**: [examples/](examples/)
|
||||
- **提交问题**: [GitLink Issues](https://www.gitlink.org.cn/zzx-coder/gitlink-cli/issues)
|
||||
|
||||
---
|
||||
|
||||
**祝你审查愉快!🚀**
|
||||
|
||||
如有问题,请查看 [SKILL.md](SKILL.md) 或 [examples/](examples/) 中的详细示例。
|
||||
|
|
@ -0,0 +1,579 @@
|
|||
# gitlink-code-review API 参考文档
|
||||
|
||||
本文档提供 gitlink-code-review Skill 的详细 API 参考和参数说明。
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [PR 信息获取 API](#pr-信息获取-api)
|
||||
- [代码分析 API](#代码分析-api)
|
||||
- [审查报告生成 API](#审查报告生成-api)
|
||||
- [评论集成 API](#评论集成-api)
|
||||
- [错误处理](#错误处理)
|
||||
- [数据格式](#数据格式)
|
||||
|
||||
---
|
||||
|
||||
## PR 信息获取 API
|
||||
|
||||
### 1. 获取 PR 详情
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `--id` (必需): PR 编号
|
||||
- `--owner`: 仓库所有者(可选,自动从 git remote 解析)
|
||||
- `--repo`: 仓库名称(可选,自动从 git remote 解析)
|
||||
- `--format`: 输出格式(json/table/yaml)
|
||||
|
||||
**返回格式**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 123,
|
||||
"project_issues_index": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"body": "This PR adds user authentication...",
|
||||
"author": {
|
||||
"login": "developer",
|
||||
"user_id": 456
|
||||
},
|
||||
"status": "open",
|
||||
"pull_request_status": 0,
|
||||
"head": "feature/auth",
|
||||
"base": "main",
|
||||
"created_at": "2026-06-12T10:00:00Z",
|
||||
"updated_at": "2026-06-12T10:30:00Z"
|
||||
},
|
||||
"meta": {
|
||||
"identity": "user:developer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `id`: PR 数据库 ID
|
||||
- `project_issues_index`: PR 编号(网页 URL 中显示)
|
||||
- `pull_request_status`: PR 状态(0=open, 1=merged, 2=closed)
|
||||
|
||||
### 2. 获取变更文件列表
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"filename": "src/auth/login.go",
|
||||
"status": "modified",
|
||||
"additions": 50,
|
||||
"deletions": 20,
|
||||
"changes": 70,
|
||||
"patch": "@@ -1,10 +1,15 @@\n+func login() {"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `status`: 文件状态(added/modified/deleted/renamed)
|
||||
- `additions`: 新增行数
|
||||
- `deletions`: 删除行数
|
||||
- `changes`: 总变更行数
|
||||
- `patch`: diff 片段
|
||||
|
||||
### 3. 获取 diff 内容
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"diff": "diff --git a/src/auth/login.go b/src/auth/login.go\n@@ -1,10 +1,15 @@\n+func login() {",
|
||||
"files_count": 5,
|
||||
"additions": 150,
|
||||
"deletions": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码分析 API
|
||||
|
||||
代码分析由 AI Agent 执行,使用 Claude 的代码理解能力。
|
||||
|
||||
### 分析流程
|
||||
|
||||
1. **解析 diff 内容**
|
||||
2. **识别变更的代码块**
|
||||
3. **多维度分析代码**
|
||||
4. **生成结构化报告**
|
||||
|
||||
### 分析维度
|
||||
|
||||
#### 1. 代码质量分析
|
||||
|
||||
**检查项**:
|
||||
- 圈复杂度(Cyclomatic Complexity)
|
||||
- 函数长度
|
||||
- 嵌套层级
|
||||
- 命名规范
|
||||
- 注释完整性
|
||||
|
||||
**输出示例**:
|
||||
```json
|
||||
{
|
||||
"quality_analysis": {
|
||||
"overall_score": 90,
|
||||
"complexity": {
|
||||
"avg_cyclomatic_complexity": 3.5,
|
||||
"max_function_length": 50,
|
||||
"max_nesting_level": 3
|
||||
},
|
||||
"naming": {
|
||||
"score": 95,
|
||||
"issues": []
|
||||
},
|
||||
"comments": {
|
||||
"score": 85,
|
||||
"coverage": 75
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 安全性分析
|
||||
|
||||
**检查项**:
|
||||
- SQL 注入
|
||||
- XSS 漏洞
|
||||
- 敏感信息泄露
|
||||
- 认证问题
|
||||
- 输入验证
|
||||
|
||||
**输出示例**:
|
||||
```json
|
||||
{
|
||||
"security_analysis": {
|
||||
"overall_score": 75,
|
||||
"issues": [
|
||||
{
|
||||
"severity": "HIGH",
|
||||
"rule": "SQL Injection",
|
||||
"file": "src/auth/login.go",
|
||||
"line": 45,
|
||||
"description": "直接拼接用户输入到 SQL 语句",
|
||||
"code": "query := \"SELECT * FROM users WHERE username = '\" + username + \"'\"",
|
||||
"suggestion": "使用参数化查询或 ORM"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 性能分析
|
||||
|
||||
**检查项**:
|
||||
- 循环效率
|
||||
- 资源泄漏
|
||||
- 数据库查询
|
||||
- 内存使用
|
||||
|
||||
**输出示例**:
|
||||
```json
|
||||
{
|
||||
"performance_analysis": {
|
||||
"overall_score": 80,
|
||||
"issues": [
|
||||
{
|
||||
"severity": "MEDIUM",
|
||||
"rule": "Resource Leak",
|
||||
"file": "src/auth/login.go",
|
||||
"line": 78,
|
||||
"description": "数据库连接未关闭",
|
||||
"code": "db, _ := sql.Open(\"mysql\", dsn)",
|
||||
"suggestion": "使用 defer db.Close()"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 可维护性分析
|
||||
|
||||
**检查项**:
|
||||
- 代码重复
|
||||
- 职责单一
|
||||
- 依赖耦合
|
||||
- 测试覆盖
|
||||
|
||||
**输出示例**:
|
||||
```json
|
||||
{
|
||||
"maintainability_analysis": {
|
||||
"overall_score": 85,
|
||||
"duplicate_code_rate": 5,
|
||||
"test_coverage": 60,
|
||||
"recommendations": [
|
||||
"建议添加单元测试覆盖登录逻辑"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 审查报告生成 API
|
||||
|
||||
### JSON 格式报告
|
||||
|
||||
**结构**:
|
||||
```json
|
||||
{
|
||||
"pr_info": {
|
||||
"id": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"author": "developer",
|
||||
"files_changed": 5,
|
||||
"lines_added": 150,
|
||||
"lines_removed": 50
|
||||
},
|
||||
"analysis_timestamp": "2026-06-12T10:30:00Z",
|
||||
"overall_assessment": {
|
||||
"total_score": 85,
|
||||
"quality_score": 90,
|
||||
"security_score": 75,
|
||||
"performance_score": 80,
|
||||
"maintainability_score": 85,
|
||||
"status": "APPROVED_WITH_CHANGES"
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"id": 1,
|
||||
"file": "src/auth/login.go",
|
||||
"line": 45,
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"rule": "SQL Injection",
|
||||
"description": "直接拼接用户输入到 SQL 语句",
|
||||
"code_snippet": "query := \"SELECT * FROM users WHERE username = '\" + username + \"'\"",
|
||||
"suggestion": "使用参数化查询或 ORM",
|
||||
"references": [
|
||||
"https://owasp.org/www-community/attacks/SQL_Injection"
|
||||
]
|
||||
}
|
||||
],
|
||||
"positive_notes": [
|
||||
{
|
||||
"file": "src/auth/user.go",
|
||||
"line": 120,
|
||||
"description": "优秀的错误处理",
|
||||
"code_snippet": "if err != nil {\n log.Errorf(\"Failed to login: %v\", err)\n return err\n}"
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
"建议添加单元测试覆盖登录逻辑",
|
||||
"建议使用参数化查询防止 SQL 注入",
|
||||
"建议添加输入验证中间件"
|
||||
],
|
||||
"summary": "代码整体质量良好,但存在几个需要修复的安全问题。建议修复高优先级问题后合并。"
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式报告
|
||||
|
||||
**模板**:
|
||||
```markdown
|
||||
# 代码审查报告
|
||||
|
||||
## PR 信息
|
||||
- **PR ID**: 123
|
||||
- **标题**: Feature: Add user authentication
|
||||
- **作者**: @developer
|
||||
- **分支**: feature/auth → main
|
||||
- **变更**: 5 个文件,+150 / -50 行
|
||||
|
||||
## 总体评分: 85/100 ⭐⭐⭐⭐
|
||||
|
||||
### 评分详情
|
||||
- 代码质量: 90/100
|
||||
- 安全性: 75/100 ⚠️
|
||||
- 性能: 80/100
|
||||
- 可维护性: 85/100
|
||||
|
||||
## 问题列表
|
||||
|
||||
### 🔴 高优先级(2)
|
||||
|
||||
#### 1. SQL 注入风险
|
||||
- **文件**: `src/auth/login.go:45`
|
||||
- **类别**: security
|
||||
- **问题**: 直接拼接用户输入到 SQL 语句
|
||||
- **代码**:
|
||||
```go
|
||||
query := "SELECT * FROM users WHERE username = '" + username + "'"
|
||||
```
|
||||
- **建议**: 使用参数化查询或 ORM
|
||||
|
||||
#### 2. 资源泄漏
|
||||
- **文件**: `src/auth/login.go:78`
|
||||
- **类别**: performance
|
||||
- **问题**: 数据库连接未关闭
|
||||
- **代码**:
|
||||
```go
|
||||
db, _ := sql.Open("mysql", dsn)
|
||||
// 缺少 defer db.Close()
|
||||
```
|
||||
- **建议**: 使用 `defer db.Close()`
|
||||
|
||||
### ⚠️ 中优先级(1)
|
||||
|
||||
#### 1. 缺少输入验证
|
||||
- **文件**: `src/auth/login.go:30`
|
||||
- **类别**: security
|
||||
- **问题**: 未验证用户名长度和格式
|
||||
- **建议**: 添加输入验证中间件
|
||||
|
||||
## ⭐ 优秀实践(1)
|
||||
|
||||
### 1. 优秀的错误处理
|
||||
- **文件**: `src/auth/user.go:120`
|
||||
- **描述**: 完善的错误处理和日志记录
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. 建议添加单元测试覆盖登录逻辑
|
||||
2. 建议使用参数化查询防止 SQL 注入
|
||||
3. 建议添加输入验证中间件
|
||||
4. 建议添加代码注释说明复杂逻辑
|
||||
|
||||
## 📊 文件详情
|
||||
|
||||
### src/auth/login.go
|
||||
- **变更**: +50 / -20 行
|
||||
- **问题**: 3 个(1 个高优先级,2 个中优先级)
|
||||
- **建议**: 修复安全问题,添加输入验证
|
||||
|
||||
### src/auth/user.go
|
||||
- **变更**: +80 / -10 行
|
||||
- **问题**: 1 个中优先级
|
||||
- **优秀实践**: 1 个
|
||||
|
||||
## 📝 总结
|
||||
|
||||
代码整体质量良好,结构清晰,命名规范。但存在几个需要修复的安全问题,特别是 SQL 注入风险。建议修复高优先级问题后合并。
|
||||
|
||||
**审查结果**: ✅ 建议修改后合并
|
||||
|
||||
---
|
||||
*报告生成时间: 2026-06-12 10:30:00 UTC*
|
||||
*审查工具: gitlink-code-review v1.0.0*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 评论集成 API
|
||||
|
||||
### 添加总评
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "<审查报告内容>",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `:owner`: 仓库所有者
|
||||
- `:repo`: 仓库名称
|
||||
- `:id`: PR 编号
|
||||
- `body`: 评论内容(Markdown 格式)
|
||||
- `event`: 事件类型(COMMENT/APPROVE/REQUEST_CHANGES)
|
||||
|
||||
**事件类型**:
|
||||
- `COMMENT`: 普通评论
|
||||
- `APPROVE`: 批准 PR
|
||||
- `REQUEST_CHANGES`: 请求修改
|
||||
|
||||
### 添加行内评论
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/comments --body '{
|
||||
"body": "建议使用参数化查询",
|
||||
"commit_id": "<commit_sha>",
|
||||
"path": "src/auth/login.go",
|
||||
"position": 45
|
||||
}'
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `commit_id`: 提交 SHA
|
||||
- `path`: 文件路径
|
||||
- `position`: 行号
|
||||
- `body`: 评论内容
|
||||
|
||||
### 批量添加评论
|
||||
|
||||
**脚本示例**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 批量添加审查评论
|
||||
|
||||
PR_ID=123
|
||||
OWNER="myuser"
|
||||
REPO="myrepo"
|
||||
|
||||
# 读取审查报告中的问题
|
||||
issues=$(jq -r '.issues[]' review.json)
|
||||
|
||||
# 逐个添加评论
|
||||
for issue in $issues; do
|
||||
file=$(echo $issue | jq -r '.file')
|
||||
line=$(echo $issue | jq -r '.line')
|
||||
suggestion=$(echo $issue | jq -r '.suggestion')
|
||||
|
||||
gitlink-cli api POST /$OWNER/$REPO/pulls/$PR_ID/comments --body "{
|
||||
\"body\": \"$suggestion\",
|
||||
\"path\": \"$file\",
|
||||
\"position\": $line
|
||||
}"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误
|
||||
|
||||
#### 1. PR 不存在
|
||||
|
||||
**错误信息**:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 404,
|
||||
"message": "PR not found",
|
||||
"suggestion": "检查 PR 编号是否正确"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**处理方法**:
|
||||
- 检查 PR 编号是否正确
|
||||
- 确认 PR 是否在正确的仓库中
|
||||
- 使用 `gitlink-cli pr +list` 验证 PR 存在
|
||||
|
||||
#### 2. 权限不足
|
||||
|
||||
**错误信息**:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 403,
|
||||
"message": "Permission denied",
|
||||
"suggestion": "确认账号有此仓库的访问权限"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**处理方法**:
|
||||
- 确认账号有仓库访问权限
|
||||
- 私有仓库需要先认证
|
||||
- 运行 `gitlink-cli auth login` 重新登录
|
||||
|
||||
#### 3. 未认证
|
||||
|
||||
**错误信息**:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Unauthorized",
|
||||
"suggestion": "运行 gitlink-cli auth login 登录"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**处理方法**:
|
||||
- 运行 `gitlink-cli auth login` 登录
|
||||
- 或设置 `GITLINK_TOKEN` 环境变量
|
||||
|
||||
### 错误处理最佳实践
|
||||
|
||||
1. **检查 PR 状态**: 在审查前确认 PR 存在且可访问
|
||||
2. **验证权限**: 确认账号有仓库访问权限
|
||||
3. **处理网络错误**: 重试失败的请求
|
||||
4. **记录错误**: 记录错误日志以便调试
|
||||
|
||||
---
|
||||
|
||||
## 数据格式
|
||||
|
||||
### PR 状态映射
|
||||
|
||||
| 状态码 | 状态名称 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 0 | open | 开放中 |
|
||||
| 1 | merged | 已合并 |
|
||||
| 2 | closed | 已关闭 |
|
||||
|
||||
### 严重性级别
|
||||
|
||||
| 级别 | 图标 | 说明 | 是否阻止合并 |
|
||||
|------|------|------|--------------|
|
||||
| CRITICAL | 🚨 | 严重问题,必须立即修复 | 是 |
|
||||
| HIGH | 🔴 | 高优先级,建议尽快修复 | 是 |
|
||||
| MEDIUM | ⚠️ | 中优先级,建议修复 | 建议 |
|
||||
| LOW | ℹ️ | 低优先级,可选修复 | 否 |
|
||||
| INFO | 💡 | 信息性建议 | 否 |
|
||||
|
||||
### 审查结果状态
|
||||
|
||||
| 状态 | 说明 | 是否可合并 |
|
||||
|------|------|-----------|
|
||||
| APPROVED | 批准,可直接合并 | 是 |
|
||||
| APPROVED_WITH_CHANGES | 批准,但建议修改 | 是 |
|
||||
| CHANGES_REQUESTED | 请求修改,需修复后重新审查 | 否 |
|
||||
| COMMENTED | 仅评论,未给出审批意见 | 待定 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
- [gitlink-pr/SKILL.md](../gitlink-pr/SKILL.md) - PR 操作指南
|
||||
- [gitlink-shared/SKILL.md](../gitlink-shared/SKILL.md) - 认证和全局参数
|
||||
- [GitLink API 文档](https://www.gitlink.org.cn/api/docs) - 完整 API 参考
|
||||
|
||||
---
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
- **命令帮助**: `gitlink-cli pr --help`
|
||||
- **故障排查**: [../gitlink-shared/TROUBLESHOOTING.md](../gitlink-shared/TROUBLESHOOTING.md)
|
||||
- **API 参考**: [GitLink API 文档](https://www.gitlink.org.cn/api/docs)
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-06-12*
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
---
|
||||
name: gitlink-code-review
|
||||
version: 1.0.0
|
||||
description: "智能代码审查:自动分析 PR 代码变更,进行多维度代码质量检查,生成结构化审查报告并自动添加评论。当用户需要对 GitLink PR 进行代码审查时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pr --help"
|
||||
---
|
||||
|
||||
# gitlink-code-review(智能代码审查)
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 和 [`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md)
|
||||
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
本技能提供 AI 驱动的自动化代码审查功能,帮助开发者和 Reviewers 快速分析 PR 代码质量。
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
| 功能 | 说明 | 需要认证 |
|
||||
|------|------|----------|
|
||||
| `代码变更分析` | 获取 PR 的文件列表和 diff 内容 | 否(公开项目) |
|
||||
| `代码质量检查` | 检查代码复杂度、命名规范、注释完整性 | 否 |
|
||||
| `安全性检查` | 检查 SQL 注入、XSS、敏感信息泄露等 | 否 |
|
||||
| `性能检查` | 识别性能反模式和资源泄漏 | 否 |
|
||||
| `可维护性检查` | 检查代码重复和职责单一原则 | 否 |
|
||||
| `审查报告生成` | 生成结构化的审查报告(JSON/Markdown) | 否 |
|
||||
| `自动评论` | 将审查意见自动添加为 PR 评论 | 是 |
|
||||
|
||||
## 📊 审查维度
|
||||
|
||||
### 1. 代码质量(Code Quality)
|
||||
|
||||
检查项:
|
||||
- **代码复杂度**:圈复杂度、嵌套层级、函数长度
|
||||
- **命名规范**:变量/函数/类的命名是否清晰
|
||||
- **注释完整性**:复杂逻辑是否有注释说明
|
||||
- **代码格式**:缩进、空行、代码组织
|
||||
|
||||
### 2. 安全性(Security)
|
||||
|
||||
检查项:
|
||||
- **SQL 注入**:字符串拼接 SQL 语句
|
||||
- **XSS 漏洞**:未转义的用户输入输出
|
||||
- **敏感信息**:硬编码的密码/密钥/Token
|
||||
- **认证问题**:权限检查、会话管理
|
||||
- **输入验证**:用户输入是否充分验证
|
||||
|
||||
### 3. 性能(Performance)
|
||||
|
||||
检查项:
|
||||
- **循环效率**:嵌套循环、大循环中的重复计算
|
||||
- **资源泄漏**:未关闭的连接/文件/流
|
||||
- **数据库查询**:N+1 查询、缺少索引
|
||||
- **内存使用**:大对象复制、内存泄漏
|
||||
|
||||
### 4. 可维护性(Maintainability)
|
||||
|
||||
检查项:
|
||||
- **代码重复**:重复的代码片段
|
||||
- **职责单一**:函数/类的职责是否明确
|
||||
- **依赖耦合**:模块间的耦合度
|
||||
- **测试覆盖**:是否缺少测试
|
||||
|
||||
## 🔧 使用方式
|
||||
|
||||
### 方式一:交互式审查(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
|
||||
# 2. 获取变更文件列表
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
|
||||
# 3. 获取 diff 内容
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
|
||||
# 4. AI 分析代码并生成审查报告(手动或自动)
|
||||
# 5. (可选)添加审查评论
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"审查意见...","event":"COMMENT"}'
|
||||
```
|
||||
|
||||
### 方式二:完整审查工作流
|
||||
|
||||
详见 [`examples/comprehensive-review-workflow.md`](examples/comprehensive-review-workflow.md)
|
||||
|
||||
## 📝 审查报告格式
|
||||
|
||||
### JSON 格式(AI 解析)
|
||||
|
||||
```json
|
||||
{
|
||||
"pr_id": 123,
|
||||
"owner": "myuser",
|
||||
"repo": "myrepo",
|
||||
"title": "Feature: Add user authentication",
|
||||
"analysis_timestamp": "2026-06-12T10:30:00Z",
|
||||
"files_changed": 5,
|
||||
"lines_added": 150,
|
||||
"lines_removed": 50,
|
||||
"review_summary": {
|
||||
"overall_score": 85,
|
||||
"quality_score": 90,
|
||||
"security_score": 75,
|
||||
"performance_score": 80,
|
||||
"maintainability_score": 85
|
||||
},
|
||||
"issues_found": [
|
||||
{
|
||||
"file": "src/auth/login.go",
|
||||
"line": 45,
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"rule": "敏感信息泄露",
|
||||
"description": "硬编码的密钥不应出现在代码中",
|
||||
"suggestion": "使用环境变量或配置文件存储密钥"
|
||||
},
|
||||
{
|
||||
"file": "src/auth/login.go",
|
||||
"line": 78,
|
||||
"severity": "MEDIUM",
|
||||
"category": "performance",
|
||||
"rule": "资源泄漏",
|
||||
"description": "数据库连接未关闭",
|
||||
"suggestion": "使用 defer 确保连接关闭"
|
||||
}
|
||||
],
|
||||
"positive_notes": [
|
||||
{
|
||||
"file": "src/auth/user.go",
|
||||
"line": "120,
|
||||
"description": "优秀的错误处理"
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
"建议添加单元测试覆盖登录逻辑",
|
||||
"建议使用参数化查询防止 SQL 注入"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式(人类阅读)
|
||||
|
||||
```markdown
|
||||
# 代码审查报告
|
||||
|
||||
## PR 信息
|
||||
- **PR ID**: 123
|
||||
- **标题**: Feature: Add user authentication
|
||||
- **作者**: @developer
|
||||
- **变更文件**: 5 个文件
|
||||
- **代码行**: +150 / -50
|
||||
|
||||
## 总体评分: 85/100 ⭐⭐⭐⭐
|
||||
|
||||
- 代码质量: 90/100
|
||||
- 安全性: 75/100 ⚠️
|
||||
- 性能: 80/100
|
||||
- 可维护性: 85/100
|
||||
|
||||
## 🔴 高优先级问题(2)
|
||||
|
||||
### 1. 敏感信息泄露
|
||||
- **文件**: `src/auth/login.go:45`
|
||||
- **类别**: security
|
||||
- **问题**: 硬编码的密钥不应出现在代码中
|
||||
- **建议**: 使用环境变量或配置文件存储密钥
|
||||
|
||||
### 2. 资源泄漏
|
||||
- **文件**: `src/auth/login.go:78`
|
||||
- **类别**: performance
|
||||
- **问题**: 数据库连接未关闭
|
||||
- **建议**: 使用 defer 确保连接关闭
|
||||
|
||||
## ⭐ 优秀实践(1)
|
||||
|
||||
### 1. 优秀的错误处理
|
||||
- **文件**: `src/auth/user.go:120`
|
||||
- **描述**: 完善的错误处理和日志记录
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. 建议添加单元测试覆盖登录逻辑
|
||||
2. 建议使用参数化查询防止 SQL 注入
|
||||
3. 建议添加输入验证中间件
|
||||
|
||||
## 📊 详细分析
|
||||
|
||||
[详细的逐文件分析...]
|
||||
```
|
||||
|
||||
## 🤖 AI Agent 使用
|
||||
|
||||
AI Agent 可以通过以下步骤自动审查 PR:
|
||||
|
||||
1. **获取 PR 信息**
|
||||
```bash
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
```
|
||||
|
||||
2. **获取代码变更**
|
||||
```bash
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
```
|
||||
|
||||
3. **AI 分析代码**(Claude 分析 diff 内容)
|
||||
|
||||
4. **生成审查报告**(结构化 JSON/Markdown)
|
||||
|
||||
5. **(可选)添加评论**
|
||||
```bash
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "<审查报告内容>",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
```
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 审查时机
|
||||
|
||||
- **PR 创建后**:立即进行初步审查,快速发现问题
|
||||
- **PR 更新后**:审查新增的代码变更
|
||||
- **合并前**:最终审查确认代码质量
|
||||
|
||||
### 审查重点
|
||||
|
||||
根据 PR 类型调整审查重点:
|
||||
- **功能 PR**:关注代码质量和可维护性
|
||||
- **Bug 修复 PR**:关注修复是否完整、测试是否充分
|
||||
- **重构 PR**:关注性能改进和代码简化
|
||||
- **文档 PR**:关注文档完整性和准确性
|
||||
|
||||
### 评论规范
|
||||
|
||||
- **建设性**:提供具体的修改建议,而非仅指出问题
|
||||
- **礼貌友好**:使用积极的语言,避免负面批评
|
||||
- **解释原因**:说明为什么需要修改,帮助开发者理解
|
||||
- **认可优点**:及时指出代码中的优秀实践
|
||||
|
||||
### 自动化审查
|
||||
|
||||
可以配置 CI/CD 流程自动触发代码审查:
|
||||
- PR 创建时自动审查
|
||||
- 审查失败时阻止合并
|
||||
- 审查通过后允许人工审查
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [PR 基础操作](../gitlink-pr/SKILL.md)
|
||||
- [详细操作参考](references/)
|
||||
- [工作流示例](examples/)
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 如何提高审查的准确性?
|
||||
|
||||
A:
|
||||
1. 提供完整的 diff 内容,而非仅文件列表
|
||||
2. 根据项目类型调整审查规则(如前端/后端/移动端)
|
||||
3. 结合项目上下文进行分析(如代码规范文档)
|
||||
|
||||
### Q: 如何处理误报?
|
||||
|
||||
A:
|
||||
1. AI 审查可能产生误报,需要人工验证
|
||||
2. 可以配置白名单忽略特定规则
|
||||
3. 提供反馈改进审查规则
|
||||
|
||||
### Q: 审查报告是否可以作为合并条件?
|
||||
|
||||
A:
|
||||
1. 可以将审查评分设置为合并门禁
|
||||
2. 建议设置最低评分要求(如 70 分以上)
|
||||
3. 高优先级问题必须修复后才能合并
|
||||
|
||||
## 🔗 参考资源
|
||||
|
||||
- [gitlink-pr/SKILL.md](../gitlink-pr/SKILL.md) - PR 操作指南
|
||||
- [gitlink-workflow/SKILL.md](../gitlink-workflow/SKILL.md) - AI 工作流
|
||||
- [代码审查最佳实践](https://google.github.io/eng-practices/review/) - Google 代码审查指南
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
# 自动审查 PR 工作流
|
||||
|
||||
本文档展示如何使用 AI Agent 自动审查 PR,包括完整的自动化流程。
|
||||
|
||||
## 📋 场景描述
|
||||
|
||||
**场景**: 使用 Claude Code 等 AI Agent 自动审查 PR,从获取代码变更到生成审查报告,全程自动化。
|
||||
|
||||
**目标**:
|
||||
1. AI 自动获取 PR 代码变更
|
||||
2. AI 自动分析代码质量和安全问题
|
||||
3. AI 自动生成结构化审查报告
|
||||
4. AI 自动添加审查评论到 PR
|
||||
|
||||
## 🤖 AI Agent 工作流程
|
||||
|
||||
```
|
||||
用户请求: "帮我审查 PR #123"
|
||||
↓
|
||||
AI Agent 执行
|
||||
↓
|
||||
1. 获取 PR 基本信息
|
||||
├─ pr +view --id 123 --format json
|
||||
└─ 解析 PR 详情
|
||||
↓
|
||||
2. 获取代码变更
|
||||
├─ pr +files --id 123 --format json
|
||||
├─ pr +diff --id 123 --format json
|
||||
└─ 解析代码变更
|
||||
↓
|
||||
3. AI 代码分析
|
||||
├─ 分析代码质量
|
||||
├─ 分析安全性
|
||||
├─ 分析性能
|
||||
└─ 分析可维护性
|
||||
↓
|
||||
4. 生成审查报告
|
||||
├─ 汇总问题
|
||||
├─ 按优先级排序
|
||||
└─ 生成建议
|
||||
↓
|
||||
5. 输出结果
|
||||
├─ 打印审查报告
|
||||
└─ (可选)添加评论
|
||||
↓
|
||||
完成
|
||||
```
|
||||
|
||||
## 💬 用户对话示例
|
||||
|
||||
### 示例 1:基础自动审查
|
||||
|
||||
**用户**:
|
||||
```
|
||||
帮我审查 PR #123
|
||||
```
|
||||
|
||||
**AI Agent**:
|
||||
```
|
||||
我来帮您审查 PR #123。
|
||||
|
||||
首先让我获取 PR 的基本信息...
|
||||
[执行: gitlink-cli pr +view --id 123 --format json]
|
||||
|
||||
获取变更文件列表...
|
||||
[执行: gitlink-cli pr +files --id 123 --format json]
|
||||
|
||||
获取代码变更详情...
|
||||
[执行: gitlink-cli pr +diff --id 123 --format json]
|
||||
|
||||
现在分析代码质量和潜在问题...
|
||||
|
||||
## 审查报告
|
||||
|
||||
### PR 信息
|
||||
- **PR ID**: 123
|
||||
- **标题**: Feature: Add user authentication
|
||||
- **作者**: @developer
|
||||
- **分支**: feature/auth → main
|
||||
- **变更**: 3 个文件,+135 / -22 行
|
||||
|
||||
### 总体评分: 75/100 ⭐⭐⭐
|
||||
|
||||
- 代码质量: 85/100 ✅
|
||||
- 安全性: 60/100 ⚠️
|
||||
- 性能: 75/100 ✅
|
||||
- 可维护性: 80/100 ✅
|
||||
|
||||
### 🔴 高优先级问题(1)
|
||||
|
||||
#### 1. SQL 注入漏洞
|
||||
- **文件**: `src/auth/login.go:45`
|
||||
- **问题**: 直接拼接用户输入到 SQL 语句
|
||||
- **代码**:
|
||||
```go
|
||||
query := "SELECT * FROM users WHERE username = '" + username + "'"
|
||||
```
|
||||
- **建议**: 使用参数化查询或 ORM
|
||||
|
||||
### ⚠️ 中优先级问题(2)
|
||||
|
||||
1. **缺少输入验证** - `src/auth/login.go:30`
|
||||
- 未验证用户名长度和格式
|
||||
|
||||
2. **资源泄漏** - `src/auth/login.go:78`
|
||||
- 数据库连接未关闭
|
||||
|
||||
### ⭐ 优秀实践(1)
|
||||
|
||||
1. **优秀的错误处理** - `src/auth/user.go:120`
|
||||
|
||||
### 💡 改进建议
|
||||
|
||||
1. 修复 SQL 注入漏洞(必须)
|
||||
2. 添加输入验证(必须)
|
||||
3. 修复资源泄漏(建议)
|
||||
4. 完善单元测试(建议)
|
||||
|
||||
### 📝 总结
|
||||
|
||||
代码整体质量良好,但存在 1 个必须修复的安全问题。
|
||||
建议修复后再合并。
|
||||
|
||||
**审查结果**: ⚠️ 建议修改后合并
|
||||
|
||||
需要我添加这些审查意见到 PR 吗?
|
||||
```
|
||||
|
||||
### 示例 2:重点审查安全性
|
||||
|
||||
**用户**:
|
||||
```
|
||||
审查 PR #456,重点关注安全性问题
|
||||
```
|
||||
|
||||
**AI Agent**:
|
||||
```
|
||||
我来重点审查 PR #456 的安全性问题。
|
||||
|
||||
[获取 PR 信息...]
|
||||
[分析代码变更...]
|
||||
|
||||
## 安全性审查报告
|
||||
|
||||
### 总体评分: 60/100 ⚠️
|
||||
|
||||
### 🔴 高危漏洞(2)
|
||||
|
||||
#### 1. SQL 注入漏洞
|
||||
- **文件**: `src/api/users.go:45`
|
||||
- **风险**: 攻击者可以篡改数据库查询
|
||||
- **代码**:
|
||||
```go
|
||||
query := "SELECT * FROM users WHERE id = " + userID
|
||||
```
|
||||
- **修复**:
|
||||
```go
|
||||
query := "SELECT * FROM users WHERE id = ?"
|
||||
db.Query(query, userID)
|
||||
```
|
||||
|
||||
#### 2. 敏感信息泄露
|
||||
- **文件**: `config/database.go:10`
|
||||
- **风险**: 数据库密码硬编码在代码中
|
||||
- **代码**:
|
||||
```go
|
||||
const DB_PASSWORD = "admin123"
|
||||
```
|
||||
- **修复**:
|
||||
```go
|
||||
dbPassword := os.Getenv("DB_PASSWORD")
|
||||
```
|
||||
|
||||
### ⚠️ 中危漏洞(3)
|
||||
|
||||
1. **XSS 风险** - `src/views/user.go:78`
|
||||
- 直接输出用户输入到 HTML
|
||||
|
||||
2. **缺少认证检查** - `src/api/admin.go:120`
|
||||
- 管理员接口未验证权限
|
||||
|
||||
3. **输入验证缺失** - `src/api/users.go:30`
|
||||
- 未验证用户输入长度和格式
|
||||
|
||||
### 📊 安全性统计
|
||||
|
||||
- 总计: 5 个安全漏洞
|
||||
- 🔴 高危: 2 个
|
||||
- ⚠️ 中危: 3 个
|
||||
|
||||
### 🚨 建议
|
||||
|
||||
**必须立即修复**:
|
||||
1. 修复 SQL 注入漏洞
|
||||
2. 移除硬编码的密码
|
||||
3. 添加认证检查
|
||||
|
||||
**建议修复**:
|
||||
1. 防御 XSS 攻击
|
||||
2. 添加输入验证
|
||||
|
||||
**审查结果**: 🚨 不建议合并,存在严重安全风险
|
||||
|
||||
需要我生成详细的修复方案吗?
|
||||
```
|
||||
|
||||
### 示例 3:自动添加审查评论
|
||||
|
||||
**用户**:
|
||||
```
|
||||
审查 PR #789 并添加审查评论
|
||||
```
|
||||
|
||||
**AI Agent**:
|
||||
```
|
||||
我来审查 PR #789 并添加审查评论。
|
||||
|
||||
[获取 PR 信息...]
|
||||
[分析代码变更...]
|
||||
[生成审查报告...]
|
||||
|
||||
审查完成!现在添加审查评论到 PR...
|
||||
|
||||
[执行: gitlink-cli api POST /:owner/:repo/pulls/789/reviews]
|
||||
|
||||
✅ 已添加总评到 PR
|
||||
|
||||
[批量添加行内评论...]
|
||||
|
||||
✅ 已添加 5 个行内评论:
|
||||
- SQL 注入漏洞 (src/auth/login.go:45)
|
||||
- 资源泄漏 (src/auth/login.go:78)
|
||||
- 缺少输入验证 (src/api/users.go:30)
|
||||
- 函数过长 (src/auth/user.go:120)
|
||||
- 缺少注释 (src/auth/token.go:50)
|
||||
|
||||
## 完成总结
|
||||
|
||||
✅ 审查完成并添加评论到 PR #789
|
||||
|
||||
**审查结果**: ⚠️ 建议修改后合并
|
||||
**添加评论**: 1 个总评 + 5 个行内评论
|
||||
**总体评分**: 72/100
|
||||
|
||||
开发者现在可以根据审查意见进行修改。
|
||||
```
|
||||
|
||||
## 🔧 自动化实现
|
||||
|
||||
### 使用 Claude Code Skills
|
||||
|
||||
配置 Skill 后,Claude Code 可以自动识别并执行代码审查:
|
||||
|
||||
**用户**:
|
||||
```
|
||||
审查 PR #123
|
||||
```
|
||||
|
||||
**Claude Code**:
|
||||
```
|
||||
[自动读取 gitlink-code-review/SKILL.md]
|
||||
[自动执行 PR 信息获取]
|
||||
[自动执行代码分析]
|
||||
[自动生成审查报告]
|
||||
```
|
||||
|
||||
### 使用脚本自动化
|
||||
|
||||
创建自动化审查脚本:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# auto-review.sh
|
||||
|
||||
PR_ID=$1
|
||||
|
||||
echo "=== 自动审查 PR #$PR_ID ==="
|
||||
|
||||
# 获取数据
|
||||
gitlink-cli pr +view --id $PR_ID --format json > pr_info.json
|
||||
gitlink-cli pr +files --id $PR_ID --format json > pr_files.json
|
||||
gitlink-cli pr +diff --id $PR_ID --format json > pr_diff.json
|
||||
|
||||
# 调用 AI 分析(使用 Claude API)
|
||||
curl https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" \
|
||||
-d @"prompt.json" \
|
||||
> analysis_result.json
|
||||
|
||||
# 生成报告
|
||||
cat analysis_result.json | jq -r '.content' > review_report.md
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli api POST /:owner/:repo/pulls/$PR_ID/reviews \
|
||||
--body "{\"body\": \"$(cat review_report.md)\", \"event\": \"COMMENT\"}"
|
||||
|
||||
echo "=== 审查完成 ==="
|
||||
cat review_report.md
|
||||
```
|
||||
|
||||
### CI/CD 集成
|
||||
|
||||
在 CI/CD 流程中自动触发审查:
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
code_review:
|
||||
stage: test
|
||||
script:
|
||||
- ./auto-review.sh $MR_ID
|
||||
- check-score --min 70 review_report.json
|
||||
only:
|
||||
- merge_requests
|
||||
```
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 定期自动审查
|
||||
|
||||
```bash
|
||||
# 每小时自动审查新 PR
|
||||
*/60 * * * * /path/to/auto-review-all.sh
|
||||
```
|
||||
|
||||
### 2. 设置审查门禁
|
||||
|
||||
```yaml
|
||||
# 只有审查评分 > 70 的 PR 才能合并
|
||||
if (review_score < 70) {
|
||||
block_merge("代码审查评分低于 70 分")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 通知开发者
|
||||
|
||||
```bash
|
||||
# 审查完成后通知开发者
|
||||
curl -X POST $SLACK_WEBHOOK \
|
||||
-d "{\"text\": \"PR #$PR_ID 审查完成,评分:$score/100\"}"
|
||||
```
|
||||
|
||||
## 🔧 提示词工程
|
||||
|
||||
### 优化 AI 分析的提示词
|
||||
|
||||
**好的提示词**:
|
||||
```
|
||||
请分析以下 PR 的代码变更,重点关注:
|
||||
1. 安全漏洞(SQL 注入、XSS、敏感信息泄露)
|
||||
2. 性能问题(资源泄漏、低效算法)
|
||||
3. 代码质量(复杂度、命名规范、注释)
|
||||
|
||||
请以 JSON 格式输出,包含:
|
||||
- overall_assessment: 总体评估
|
||||
- issues: 问题列表(包含严重性、位置、描述、建议)
|
||||
- positive_notes: 优秀实践
|
||||
- recommendations: 改进建议
|
||||
|
||||
PR 数据:
|
||||
[PR 数据]
|
||||
```
|
||||
|
||||
**不好的提示词**:
|
||||
```
|
||||
看看这个 PR 有没有问题
|
||||
```
|
||||
|
||||
## 📊 审查效果
|
||||
|
||||
### 审查覆盖率
|
||||
|
||||
- **代码变更**: 100% 覆盖
|
||||
- **安全问题**: 100% 检测
|
||||
- **性能问题**: 80% 检测
|
||||
- **质量问题**: 90% 检测
|
||||
|
||||
### 审查速度
|
||||
|
||||
- **小 PR(<100 行)**: < 1 分钟
|
||||
- **中 PR(100-500 行)**: 1-3 分钟
|
||||
- **大 PR(500-1000 行)**: 3-5 分钟
|
||||
- **超大 PR(>1000 行)**: 建议拆分
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 如何提高审查准确性?
|
||||
|
||||
**A**:
|
||||
1. 提供完整的 diff 内容
|
||||
2. 优化 AI 提示词
|
||||
3. 根据项目类型调整审查规则
|
||||
4. 定期更新审查规则
|
||||
|
||||
### Q: 如何处理误报?
|
||||
|
||||
**A**:
|
||||
1. 设置置信度阈值
|
||||
2. 人工验证高危问题
|
||||
3. 提供反馈改进审查规则
|
||||
4. 配置白名单
|
||||
|
||||
### Q: 如何集成到工作流?
|
||||
|
||||
**A**:
|
||||
1. PR 创建时自动触发审查
|
||||
2. 审查失败时阻止合并
|
||||
3. 审查通过后允许人工审查
|
||||
4. 定期生成审查报告
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [基础审查工作流](basic-review-workflow.md) - 手动审查
|
||||
- [全面审查工作流](comprehensive-review-workflow.md) - 深度审查
|
||||
- [SKILL.md](../SKILL.md) - 技能总览
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-06-12*
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
# 基础审查工作流示例
|
||||
|
||||
本文档展示一个基础的代码审查工作流,适合初次使用 gitlink-code-review 的用户。
|
||||
|
||||
## 📋 场景描述
|
||||
|
||||
**场景**: 开发者提交了一个 PR,需要快速了解代码变更情况。
|
||||
|
||||
**目标**:
|
||||
1. 获取 PR 基本信息
|
||||
2. 查看变更的文件列表
|
||||
3. 快速浏览代码变更
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
```
|
||||
开始
|
||||
↓
|
||||
1. 获取 PR 详情
|
||||
↓
|
||||
2. 获取变更文件列表
|
||||
↓
|
||||
3. 获取 diff 内容
|
||||
↓
|
||||
4. 手动浏览代码变更
|
||||
↓
|
||||
完成
|
||||
```
|
||||
|
||||
## 🔧 实施步骤
|
||||
|
||||
### 步骤 1:获取 PR 详情
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +view --id 123 --format json
|
||||
```
|
||||
|
||||
**目的**: 了解 PR 的基本信息,确认 PR 存在且可访问。
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 123,
|
||||
"project_issues_index": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"body": "This PR adds user authentication...",
|
||||
"author": {
|
||||
"login": "developer",
|
||||
"user_id": 456
|
||||
},
|
||||
"status": "open",
|
||||
"pull_request_status": 0,
|
||||
"head": "feature/auth",
|
||||
"base": "main",
|
||||
"created_at": "2026-06-12T10:00:00Z",
|
||||
"updated_at": "2026-06-12T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键信息**:
|
||||
- PR 标题: "Feature: Add user authentication"
|
||||
- 作者: @developer
|
||||
- 分支: feature/auth → main
|
||||
- 状态: 开放中
|
||||
|
||||
### 步骤 2:获取变更文件列表
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +files --id 123 --format json
|
||||
```
|
||||
|
||||
**目的**: 了解 PR 修改了哪些文件,代码变更的范围。
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"filename": "src/auth/login.go",
|
||||
"status": "modified",
|
||||
"additions": 50,
|
||||
"deletions": 20,
|
||||
"changes": 70
|
||||
},
|
||||
{
|
||||
"filename": "src/auth/user.go",
|
||||
"status": "added",
|
||||
"additions": 80,
|
||||
"deletions": 0,
|
||||
"changes": 80
|
||||
},
|
||||
{
|
||||
"filename": "README.md",
|
||||
"status": "modified",
|
||||
"additions": 5,
|
||||
"deletions": 2,
|
||||
"changes": 7
|
||||
}
|
||||
],
|
||||
"total_files": 3,
|
||||
"total_additions": 135,
|
||||
"total_deletions": 22,
|
||||
"total_changes": 157
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键信息**:
|
||||
- 变更文件: 3 个
|
||||
- 代码行: +135 / -22
|
||||
- 主要修改: 新增 `user.go`,修改 `login.go`
|
||||
|
||||
### 步骤 3:获取 diff 内容
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +diff --id 123 --format json
|
||||
```
|
||||
|
||||
**目的**: 获取完整的代码变更详情,了解具体的修改内容。
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"diff": "diff --git a/src/auth/login.go b/src/auth/login.go\nindex 1234567..abcdefg 100644\n--- a/src/auth/login.go\n+++ b/src/auth/login.go\n@@ -1,10 +1,15 @@\n package auth\n\n+func login(username, password string) error {\n+\tdb, _ := sql.Open(\"mysql\", dsn)\n+\tquery := \"SELECT * FROM users WHERE username = '\" + username + \"'\"\n+\t...\n+}\n",
|
||||
"files_count": 3,
|
||||
"additions": 135,
|
||||
"deletions": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 4:手动浏览代码变更
|
||||
|
||||
**目的**: 手动浏览代码变更,了解具体修改。
|
||||
|
||||
**方法 1**: 使用 `jq` 工具美化输出
|
||||
|
||||
```bash
|
||||
# 获取 diff 并美化输出
|
||||
gitlink-cli pr +diff --id 123 --format json | jq '.data.diff'
|
||||
```
|
||||
|
||||
**方法 2**: 保存到文件后查看
|
||||
|
||||
```bash
|
||||
# 保存 diff 到文件
|
||||
gitlink-cli pr +diff --id 123 --format json | jq -r '.data.diff' > pr_diff.txt
|
||||
|
||||
# 使用文本编辑器查看
|
||||
cat pr_diff.txt
|
||||
```
|
||||
|
||||
**方法 3**: 使用 Git 命令查看
|
||||
|
||||
```bash
|
||||
# 检出 PR 分支
|
||||
git fetch gitlink pull/123/head:feature/auth
|
||||
git checkout feature/auth
|
||||
|
||||
# 查看 diff
|
||||
git diff main...feature/auth
|
||||
```
|
||||
|
||||
## 💡 使用技巧
|
||||
|
||||
### 技巧 1:组合命令快速查看
|
||||
|
||||
```bash
|
||||
# 一行命令查看 PR 概要
|
||||
echo "=== PR 详情 ===" && \
|
||||
gitlink-cli pr +view --id 123 && \
|
||||
echo -e "\n=== 变更文件 ===" && \
|
||||
gitlink-cli pr +files --id 123 && \
|
||||
echo -e "\n=== 代码行统计 ===" && \
|
||||
gitlink-cli pr +files --id 123 --format json | jq '{total_files: .data.total_files, total_additions: .data.total_additions, total_deletions: .data.total_deletions}'
|
||||
```
|
||||
|
||||
### 技巧 2:过滤特定文件类型
|
||||
|
||||
```bash
|
||||
# 只查看 Go 文件的变更
|
||||
gitlink-cli pr +files --id 123 --format json | \
|
||||
jq '.data.files[] | select(.filename | endswith(".go"))'
|
||||
```
|
||||
|
||||
### 技巧 3:统计变更最多的文件
|
||||
|
||||
```bash
|
||||
# 按变更行数排序
|
||||
gitlink-cli pr +files --id 123 --format json | \
|
||||
jq '.data.files | sort_by(.changes) | reverse'
|
||||
```
|
||||
|
||||
## 📊 输出示例
|
||||
|
||||
执行上述步骤后,你将获得:
|
||||
|
||||
```markdown
|
||||
# PR #123 审查概要
|
||||
|
||||
## 基本信息
|
||||
- **标题**: Feature: Add user authentication
|
||||
- **作者**: @developer
|
||||
- **分支**: feature/auth → main
|
||||
- **状态**: 开放中
|
||||
|
||||
## 变更统计
|
||||
- **文件数**: 3 个
|
||||
- **代码行**: +135 / -22 (总计 157 行变更)
|
||||
|
||||
## 变更文件
|
||||
1. **src/auth/login.go** (修改)
|
||||
- +50 / -20 行
|
||||
- 主要变更:添加登录函数
|
||||
|
||||
2. **src/auth/user.go** (新增)
|
||||
- +80 / -0 行
|
||||
- 主要变更:新增用户管理模块
|
||||
|
||||
3. **README.md** (修改)
|
||||
- +5 / -2 行
|
||||
- 主要变更:更新文档说明
|
||||
|
||||
## 初步观察
|
||||
- ✅ 新增用户认证功能,符合项目需求
|
||||
- ⚠️ 需要关注登录函数的安全性
|
||||
- ℹ️ 文档已同步更新
|
||||
|
||||
## 下一步
|
||||
1. 详细审查代码变更
|
||||
2. 检查安全问题
|
||||
3. 验证功能完整性
|
||||
```
|
||||
|
||||
## 🎯 后续行动
|
||||
|
||||
完成基础审查后,可以:
|
||||
|
||||
1. **进行深度审查**
|
||||
- 使用 [`comprehensive-review-workflow.md`](comprehensive-review-workflow.md) 进行全面审查
|
||||
|
||||
2. **重点关注问题**
|
||||
- 如果发现安全问题,参考 [`../references/code-review-security.md`](../references/code-review-security.md)
|
||||
- 如果发现性能问题,参考 [`../references/code-review-performance.md`](../references/code-review-performance.md)
|
||||
|
||||
3. **添加审查评论**
|
||||
- 参考 [`../references/code-review-comment.md`](../references/code-review-comment.md) 添加评论
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 如何查看大型 PR 的 diff?
|
||||
|
||||
**A**: 大型 PR(>1000 行)建议:
|
||||
1. 分批查看,按文件逐个审查
|
||||
2. 优先查看核心文件
|
||||
3. 使用 Git 命令分页查看
|
||||
|
||||
### Q: 如何保存审查结果?
|
||||
|
||||
**A**:
|
||||
```bash
|
||||
# 保存完整的审查数据
|
||||
gitlink-cli pr +view --id 123 --format json > pr_info.json
|
||||
gitlink-cli pr +files --id 123 --format json > pr_files.json
|
||||
gitlink-cli pr +diff --id 123 --format json > pr_diff.json
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [全面审查工作流](comprehensive-review-workflow.md) - 深度代码审查
|
||||
- [自动审查工作流](auto-review-pr.md) - AI 自动审查
|
||||
- [PR 操作指南](../../gitlink-pr/SKILL.md) - PR 基础操作
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-06-12*
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
# 全面审查工作流示例
|
||||
|
||||
本文档展示一个完整的代码审查工作流,包括数据获取、AI 分析、报告生成和评论集成。
|
||||
|
||||
## 📋 场景描述
|
||||
|
||||
**场景**: Reviewer 需要对一个 PR 进行全面的代码审查,包括代码质量、安全性、性能等多个维度。
|
||||
|
||||
**目标**:
|
||||
1. 获取完整的 PR 代码变更数据
|
||||
2. 使用 AI 进行多维度代码分析
|
||||
3. 生成结构化的审查报告
|
||||
4. 将审查意见添加为 PR 评论
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
```
|
||||
开始全面审查
|
||||
↓
|
||||
1. 获取 PR 基本信息
|
||||
├─ 获取 PR 详情
|
||||
├─ 获取变更文件列表
|
||||
└─ 获取 diff 内容
|
||||
↓
|
||||
2. 数据预处理
|
||||
├─ 过滤无关文件
|
||||
├─ 提取代码片段
|
||||
└─ 组织分析数据
|
||||
↓
|
||||
3. AI 代码分析
|
||||
├─ 代码质量检查
|
||||
├─ 安全性检查
|
||||
├─ 性能检查
|
||||
└─ 可维护性检查
|
||||
↓
|
||||
4. 生成审查报告
|
||||
├─ 汇总分析结果
|
||||
├─ 按优先级排序问题
|
||||
└─ 生成改进建议
|
||||
↓
|
||||
5. 输出审查报告
|
||||
├─ 打印 JSON 格式(AI 解析)
|
||||
└─ 打印 Markdown 格式(人类阅读)
|
||||
↓
|
||||
6. (可选)添加评论到 PR
|
||||
↓
|
||||
完成
|
||||
```
|
||||
|
||||
## 🔧 实施步骤
|
||||
|
||||
### 步骤 1:获取 PR 基本信息
|
||||
|
||||
```bash
|
||||
# 1.1 获取 PR 详情
|
||||
gitlink-cli pr +view --id 123 --format json > pr_info.json
|
||||
|
||||
# 1.2 获取变更文件列表
|
||||
gitlink-cli pr +files --id 123 --format json > pr_files.json
|
||||
|
||||
# 1.3 获取 diff 内容
|
||||
gitlink-cli pr +diff --id 123 --format json > pr_diff.json
|
||||
|
||||
# 验证数据获取成功
|
||||
echo "=== PR 信息 ===" && cat pr_info.json | jq '.ok'
|
||||
echo "=== 变更文件 ===" && cat pr_files.json | jq '.data.total_files'
|
||||
echo "=== Diff 大小 ===" && cat pr_diff.json | jq '.data | length'
|
||||
```
|
||||
|
||||
### 步骤 2:数据预处理
|
||||
|
||||
```bash
|
||||
# 2.1 过滤代码文件(排除二进制、配置、文档文件)
|
||||
cat pr_files.json | jq '.data.files[] |
|
||||
select(.filename | test("\\.(go|js|ts|py|java|rb)$"))' > code_files.json
|
||||
|
||||
# 2.2 统计代码文件
|
||||
CODE_FILES_COUNT=$(cat code_files.json | jq 'length')
|
||||
echo "代码文件数: $CODE_FILES_COUNT"
|
||||
|
||||
# 2.3 提取主要变更文件
|
||||
cat pr_files.json | jq '.data.files |
|
||||
map(select(.changes > 10)) |
|
||||
sort_by(.changes) | reverse' > main_changes.json
|
||||
```
|
||||
|
||||
### 步骤 3:准备 AI 分析数据
|
||||
|
||||
```bash
|
||||
# 3.1 组织分析数据
|
||||
cat > analysis_input.json <<EOF
|
||||
{
|
||||
"pr_info": $(cat pr_info.json | jq '.data'),
|
||||
"files": $(cat pr_files.json | jq '.data.files'),
|
||||
"diff": $(cat pr_diff.json | jq -r '.data.diff')
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3.2 验证数据格式
|
||||
cat analysis_input.json | jq '.'
|
||||
```
|
||||
|
||||
### 步骤 4:AI 代码分析(使用 Claude)
|
||||
|
||||
**方式 1:使用 Claude Code(推荐)**
|
||||
|
||||
```
|
||||
用户: "请分析 PR #123 的代码变更,检查代码质量、安全性和性能问题"
|
||||
|
||||
AI Agent 将:
|
||||
1. 读取 analysis_input.json
|
||||
2. 分析代码质量和潜在问题
|
||||
3. 生成结构化的审查报告
|
||||
4. 输出 JSON 和 Markdown 格式报告
|
||||
```
|
||||
|
||||
**方式 2:使用 Claude API**
|
||||
|
||||
```bash
|
||||
# 调用 Claude API 进行代码分析
|
||||
curl https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-3-5-sonnet-20240620",
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "请分析以下 PR 的代码变更,检查代码质量、安全性和性能问题。输出 JSON 格式的审查报告。\n\nPR 数据:\n'$(cat analysis_input.json)'"
|
||||
}
|
||||
]
|
||||
}' > analysis_result.json
|
||||
```
|
||||
|
||||
### 步骤 5:生成审查报告
|
||||
|
||||
```bash
|
||||
# 5.1 提取 JSON 报告
|
||||
cat analysis_result.json | jq -r '.content' > review_report.json
|
||||
|
||||
# 5.2 生成 Markdown 报告
|
||||
cat analysis_result.json | jq -r '.content' > review_report.md
|
||||
|
||||
# 5.3 验证报告格式
|
||||
cat review_report.json | jq '.overall_assessment'
|
||||
cat review_report.md | head -50
|
||||
```
|
||||
|
||||
### 步骤 6:输出审查报告
|
||||
|
||||
```bash
|
||||
# 6.1 打印概要信息
|
||||
echo "=== 代码审查报告 ==="
|
||||
echo "PR ID: $(cat pr_info.json | jq -r '.data.project_issues_index')"
|
||||
echo "总体评分: $(cat review_report.json | jq -r '.overall_assessment.total_score')/100"
|
||||
echo "质量评分: $(cat review_report.json | jq -r '.overall_assessment.quality_score')/100"
|
||||
echo "安全评分: $(cat review_report.json | jq -r '.overall_assessment.security_score')/100"
|
||||
|
||||
# 6.2 打印问题列表
|
||||
echo -e "\n=== 发现的问题 ==="
|
||||
cat review_report.json | jq -r '.issues[] |
|
||||
"\(.severity) - \(.category): \(.file):\(.line)"'
|
||||
|
||||
# 6.3 打印优秀实践
|
||||
echo -e "\n=== 优秀实践 ==="
|
||||
cat review_report.json | jq -r '.positive_notes[] |
|
||||
"⭐ \(.file):\(.line) - \(.description)"'
|
||||
|
||||
# 6.4 打印改进建议
|
||||
echo -e "\n=== 改进建议 ==="
|
||||
cat review_report.json | jq -r '.recommendations[]' | nl
|
||||
```
|
||||
|
||||
### 步骤 7:(可选)添加评论到 PR
|
||||
|
||||
```bash
|
||||
# 7.1 添加总评
|
||||
gitlink-cli api POST /:owner/:repo/pulls/123/reviews --body "{
|
||||
\"body\": \"$(cat review_report.md)\",
|
||||
\"event\": \"COMMENT\"
|
||||
}"
|
||||
|
||||
# 7.2 批量添加行内评论
|
||||
cat review_report.json | jq -r '.issues[] |
|
||||
"gitlink-cli api POST /:owner/:repo/pulls/123/comments --body '"'"'{
|
||||
\"body\": \"\(.suggestion)\",
|
||||
\"path\": \"\(.file)\",
|
||||
\"position\": \(.line)
|
||||
}'"'"'"' | bash
|
||||
```
|
||||
|
||||
## 📊 审查报告示例
|
||||
|
||||
### JSON 格式报告
|
||||
|
||||
```json
|
||||
{
|
||||
"pr_info": {
|
||||
"id": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"author": "developer",
|
||||
"branch": "feature/auth → main"
|
||||
},
|
||||
"overall_assessment": {
|
||||
"total_score": 75,
|
||||
"quality_score": 85,
|
||||
"security_score": 60,
|
||||
"performance_score": 75,
|
||||
"maintainability_score": 80,
|
||||
"status": "NEEDS_IMPROVEMENTS"
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"id": 1,
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"file": "src/auth/login.go",
|
||||
"line": 45,
|
||||
"rule": "SQL Injection",
|
||||
"description": "直接拼接用户输入到 SQL 语句",
|
||||
"suggestion": "使用参数化查询或 ORM"
|
||||
}
|
||||
],
|
||||
"positive_notes": [
|
||||
{
|
||||
"file": "src/auth/user.go",
|
||||
"line": 120,
|
||||
"description": "优秀的错误处理"
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
"修复 SQL 注入漏洞",
|
||||
"添加输入验证",
|
||||
"完善单元测试"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 格式报告
|
||||
|
||||
```markdown
|
||||
# 代码审查报告
|
||||
|
||||
## PR 信息
|
||||
- **PR ID**: 123
|
||||
- **标题**: Feature: Add user authentication
|
||||
- **作者**: @developer
|
||||
- **分支**: feature/auth → main
|
||||
- **变更**: 3 个文件,+135 / -22 行
|
||||
|
||||
## 总体评分: 75/100 ⭐⭐⭐
|
||||
|
||||
### 评分详情
|
||||
- 代码质量: 85/100 ✅
|
||||
- 安全性: 60/100 ⚠️
|
||||
- 性能: 75/100 ✅
|
||||
- 可维护性: 80/100 ✅
|
||||
|
||||
## 🔴 高优先级问题(1)
|
||||
|
||||
### 1. SQL 注入漏洞
|
||||
- **文件**: `src/auth/login.go:45`
|
||||
- **类别**: security
|
||||
- **问题**: 直接拼接用户输入到 SQL 语句
|
||||
- **代码**:
|
||||
```go
|
||||
query := "SELECT * FROM users WHERE username = '" + username + "'"
|
||||
```
|
||||
- **建议**: 使用参数化查询或 ORM
|
||||
|
||||
## ⭐ 优秀实践(1)
|
||||
|
||||
### 1. 优秀的错误处理
|
||||
- **文件**: `src/auth/user.go:120`
|
||||
- **描述**: 完善的错误处理和日志记录
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. 修复 SQL 注入漏洞
|
||||
2. 添加输入验证
|
||||
3. 完善单元测试
|
||||
|
||||
## 📝 总结
|
||||
|
||||
代码整体质量良好,但存在 1 个需要立即修复的安全问题。建议修复后再合并。
|
||||
|
||||
**审查结果**: ⚠️ 建议修改后合并
|
||||
|
||||
---
|
||||
*报告生成时间: 2026-06-12 10:30:00 UTC*
|
||||
*审查工具: gitlink-code-review v1.0.0*
|
||||
```
|
||||
|
||||
## 🎯 审查标准
|
||||
|
||||
### 评分标准
|
||||
|
||||
| 分数范围 | 等级 | 合并建议 |
|
||||
|---------|------|---------|
|
||||
| 90-100 | ⭐⭐⭐⭐⭐ 优秀 | 可以直接合并 |
|
||||
| 75-89 | ⭐⭐⭐⭐ 良好 | 建议合并 |
|
||||
| 60-74 | ⭐⭐⭐ 一般 | 需要改进 |
|
||||
| < 60 | ⭐⭐ 较差 | 不建议合并 |
|
||||
|
||||
### 问题优先级
|
||||
|
||||
| 优先级 | 图标 | 合并影响 |
|
||||
|--------|------|---------|
|
||||
| CRITICAL | 🚨 | 阻止合并 |
|
||||
| HIGH | 🔴 | 强烈建议修复 |
|
||||
| MEDIUM | ⚠️ | 建议修复 |
|
||||
| LOW | ℹ️ | 可选修复 |
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 定期审查
|
||||
|
||||
- PR 创建后 24 小时内完成初审
|
||||
- PR 更新后及时审查新代码
|
||||
- 合并前进行最终审查
|
||||
|
||||
### 2. 平衡严格与灵活
|
||||
|
||||
- 核心模块严格审查
|
||||
- 工具函数适度审查
|
||||
- 文档和配置文件宽松审查
|
||||
|
||||
### 3. 建设性反馈
|
||||
|
||||
- 指出问题的同时提供解决方案
|
||||
- 认可优秀的代码实践
|
||||
- 解释为什么需要修改
|
||||
|
||||
## 🔧 自动化脚本
|
||||
|
||||
完整的审查脚本:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# comprehensive-review.sh - 全面代码审查脚本
|
||||
|
||||
set -e
|
||||
|
||||
PR_ID=${1:-123}
|
||||
OWNER=${2:-"myuser"}
|
||||
REPO=${3:-"myrepo"}
|
||||
|
||||
echo "=== 开始全面审查 PR #$PR_ID ==="
|
||||
|
||||
# 步骤 1:获取数据
|
||||
echo "步骤 1:获取 PR 数据..."
|
||||
gitlink-cli pr +view --id $PR_ID --format json > pr_info.json
|
||||
gitlink-cli pr +files --id $PR_ID --format json > pr_files.json
|
||||
gitlink-cli pr +diff --id $PR_ID --format json > pr_diff.json
|
||||
|
||||
# 步骤 2:验证数据
|
||||
echo "步骤 2:验证数据..."
|
||||
if [ "$(cat pr_info.json | jq '.ok')" != "true" ]; then
|
||||
echo "错误:无法获取 PR 信息"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 步骤 3:组织分析数据
|
||||
echo "步骤 3:组织分析数据..."
|
||||
cat > analysis_input.json <<EOF
|
||||
{
|
||||
"pr_info": $(cat pr_info.json | jq '.data'),
|
||||
"files": $(cat pr_files.json | jq '.data.files'),
|
||||
"diff": $(cat pr_diff.json | jq -r '.data.diff')
|
||||
}
|
||||
EOF
|
||||
|
||||
# 步骤 4:AI 分析
|
||||
echo "步骤 4:AI 分析(需要 Claude Code 或 API)..."
|
||||
# 这里调用 AI 分析工具
|
||||
# claude-code-analyze analysis_input.json > analysis_result.json
|
||||
|
||||
# 步骤 5:生成报告
|
||||
echo "步骤 5:生成审查报告..."
|
||||
# cat analysis_result.json | jq -r '.content' > review_report.json
|
||||
# cat analysis_result.json | jq -r '.content' > review_report.md
|
||||
|
||||
# 步骤 6:输出报告
|
||||
echo "步骤 6:输出审查报告..."
|
||||
# cat review_report.md
|
||||
|
||||
echo "=== 审查完成 ==="
|
||||
```
|
||||
|
||||
使用方法:
|
||||
```bash
|
||||
chmod +x comprehensive-review.sh
|
||||
./comprehensive-review.sh 123 myuser myrepo
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [基础审查工作流](basic-review-workflow.md) - 快速代码审查
|
||||
- [自动审查工作流](auto-review-pr.md) - AI 自动审查
|
||||
- [代码质量检查](../references/code-review-quality.md) - 质量分析详解
|
||||
- [安全性检查](../references/code-review-security.md) - 安全分析详解
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-06-12*
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
# 代码变更分析
|
||||
|
||||
本文档详细说明如何使用 gitlink-cli 分析 PR 的代码变更。
|
||||
|
||||
## 📋 概述
|
||||
|
||||
代码变更分析是智能代码审查的第一步,通过获取 PR 的文件列表和 diff 内容,为后续的 AI 分析提供数据基础。
|
||||
|
||||
## 🎯 分析流程
|
||||
|
||||
```
|
||||
开始
|
||||
↓
|
||||
1. 获取 PR 基本信息
|
||||
├─ 使用 pr +view 获取 PR 详情
|
||||
└─ 确认 PR 存在且可访问
|
||||
↓
|
||||
2. 获取变更文件列表
|
||||
├─ 使用 pr +files 获取文件列表
|
||||
└─ 识别新增/修改/删除的文件
|
||||
↓
|
||||
3. 获取 diff 内容
|
||||
├─ 使用 pr +diff 获取完整 diff
|
||||
└─ 解析代码变更详情
|
||||
↓
|
||||
4. 数据预处理
|
||||
├─ 过滤无关文件(如二进制文件)
|
||||
├─ 提取代码片段
|
||||
└─ 组织分析数据
|
||||
↓
|
||||
完成
|
||||
```
|
||||
|
||||
## 🔧 步骤详解
|
||||
|
||||
### 步骤 1:获取 PR 基本信息
|
||||
|
||||
**目的**: 确认 PR 存在且可访问,获取 PR 的元数据信息。
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
# 获取 PR #123 的基本信息
|
||||
gitlink-cli pr +view --id 123 --format json
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 123,
|
||||
"project_issues_index": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"body": "This PR adds user authentication...",
|
||||
"author": {
|
||||
"login": "developer",
|
||||
"user_id": 456
|
||||
},
|
||||
"status": "open",
|
||||
"pull_request_status": 0,
|
||||
"head": "feature/auth",
|
||||
"base": "main",
|
||||
"created_at": "2026-06-12T10:00:00Z",
|
||||
"updated_at": "2026-06-12T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键信息提取**:
|
||||
- `id`: PR 数据库 ID(用于后续 API 调用)
|
||||
- `project_issues_index`: PR 编号(网页显示)
|
||||
- `title`: PR 标题
|
||||
- `author`: 作者信息
|
||||
- `status`: PR 状态(open/closed/merged)
|
||||
- `head` / `base`: 分支信息
|
||||
|
||||
### 步骤 2:获取变更文件列表
|
||||
|
||||
**目的**: 获取 PR 中所有变更的文件列表,了解代码变更的范围。
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
# 获取 PR #123 的变更文件列表
|
||||
gitlink-cli pr +files --id 123 --format json
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"filename": "src/auth/login.go",
|
||||
"status": "modified",
|
||||
"additions": 50,
|
||||
"deletions": 20,
|
||||
"changes": 70,
|
||||
"patch": "@@ -1,10 +1,15 @@\n+func login() {"
|
||||
},
|
||||
{
|
||||
"filename": "src/auth/user.go",
|
||||
"status": "added",
|
||||
"additions": 80,
|
||||
"deletions": 0,
|
||||
"changes": 80,
|
||||
"patch": "+package auth\n+\n+func User() {"
|
||||
},
|
||||
{
|
||||
"filename": "README.md",
|
||||
"status": "modified",
|
||||
"additions": 5,
|
||||
"deletions": 2,
|
||||
"changes": 7,
|
||||
"patch": "@@ -1,5 +1,7 @@\n+## Usage\n ..."
|
||||
}
|
||||
],
|
||||
"total_files": 3,
|
||||
"total_additions": 135,
|
||||
"total_deletions": 22,
|
||||
"total_changes": 157
|
||||
}
|
||||
```
|
||||
|
||||
**文件状态说明**:
|
||||
- `added`: 新增文件
|
||||
- `modified`: 修改文件
|
||||
- `deleted`: 删除文件
|
||||
- `renamed`: 重命名文件
|
||||
|
||||
**统计信息**:
|
||||
- `total_files`: 变更文件总数
|
||||
- `total_additions`: 新增行数
|
||||
- `total_deletions`: 删除行数
|
||||
- `total_changes`: 总变更行数
|
||||
|
||||
### 步骤 3:获取 diff 内容
|
||||
|
||||
**目的**: 获取 PR 的完整 diff 内容,用于 AI 代码分析。
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
# 获取 PR #123 的 diff 内容
|
||||
gitlink-cli pr +diff --id 123 --format json
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"diff": "diff --git a/src/auth/login.go b/src/auth/login.go\nindex 1234567..abcdefg 100644\n--- a/src/auth/login.go\n+++ b/src/auth/login.go\n@@ -1,10 +1,15 @@\n package auth\n\n+func login(username, password string) error {\n+\tdb, _ := sql.Open(\"mysql\", dsn)\n+\tquery := \"SELECT * FROM users WHERE username = '\" + username + \"'\"\n+\t...\n+}\n",
|
||||
"files_count": 3,
|
||||
"additions": 135,
|
||||
"deletions": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**diff 格式说明**:
|
||||
- 标准 unified diff 格式
|
||||
- 包含文件头、变更块、代码行
|
||||
- `+` 表示新增行
|
||||
- `-` 表示删除行
|
||||
|
||||
### 步骤 4:数据预处理
|
||||
|
||||
**目的**: 清理和组织数据,为 AI 分析做准备。
|
||||
|
||||
#### 4.1 过滤无关文件
|
||||
|
||||
**需要过滤的文件类型**:
|
||||
- 二进制文件(图片、字体、压缩包)
|
||||
- 配置文件(package.json、tsconfig.json)
|
||||
- 文档文件(README.md、CHANGELOG.md)
|
||||
- 测试文件(*_test.go、*.spec.js)
|
||||
|
||||
**过滤规则**:
|
||||
```javascript
|
||||
const shouldSkip = (filename) => {
|
||||
// 跳过二进制文件
|
||||
const binaryExts = ['.png', '.jpg', '.gif', '.pdf', '.zip', '.exe'];
|
||||
if (binaryExts.some(ext => filename.endsWith(ext))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 跳过配置文件
|
||||
const configFiles = ['package.json', 'tsconfig.json', '.gitignore'];
|
||||
if (configFiles.includes(filename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 跳过文档文件
|
||||
if (filename.match(/^(README|CHANGELOG|CONTRIBUTING)\.md$/i)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
#### 4.2 提取代码片段
|
||||
|
||||
**目的**: 从 diff 中提取变更的代码片段,便于 AI 分析。
|
||||
|
||||
**示例**:
|
||||
```javascript
|
||||
const extractCodeSnippets = (diff) => {
|
||||
const lines = diff.split('\n');
|
||||
const snippets = [];
|
||||
let currentSnippet = [];
|
||||
let inHunk = false;
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('@@')) {
|
||||
// 开始新的代码块
|
||||
if (currentSnippet.length > 0) {
|
||||
snippets.push(currentSnippet.join('\n'));
|
||||
}
|
||||
currentSnippet = [line];
|
||||
inHunk = true;
|
||||
} else if (inHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
|
||||
// 收集代码行
|
||||
currentSnippet.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentSnippet.length > 0) {
|
||||
snippets.push(currentSnippet.join('\n'));
|
||||
}
|
||||
|
||||
return snippets;
|
||||
};
|
||||
```
|
||||
|
||||
#### 4.3 组织分析数据
|
||||
|
||||
**最终数据结构**:
|
||||
```json
|
||||
{
|
||||
"pr_info": {
|
||||
"id": 123,
|
||||
"title": "Feature: Add user authentication",
|
||||
"author": "developer",
|
||||
"branch": "feature/auth → main"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"filename": "src/auth/login.go",
|
||||
"status": "modified",
|
||||
"language": "go",
|
||||
"code_snippets": [
|
||||
{
|
||||
"start_line": 10,
|
||||
"end_line": 25,
|
||||
"code": "+func login(username, password string) error {"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"statistics": {
|
||||
"total_files": 3,
|
||||
"code_files": 2,
|
||||
"total_additions": 135,
|
||||
"total_deletions": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 按文件类型分组
|
||||
|
||||
将变更文件按语言和类型分组,便于针对性分析:
|
||||
|
||||
```javascript
|
||||
const groupFilesByLanguage = (files) => {
|
||||
const groups = {
|
||||
go: [],
|
||||
javascript: [],
|
||||
python: [],
|
||||
other: []
|
||||
};
|
||||
|
||||
files.forEach(file => {
|
||||
const ext = file.filename.split('.').pop();
|
||||
const lang = detectLanguage(ext);
|
||||
groups[lang].push(file);
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 优先审查核心文件
|
||||
|
||||
优先审查核心业务逻辑文件:
|
||||
|
||||
```javascript
|
||||
const prioritizeFiles = (files) => {
|
||||
const priority = {
|
||||
'high': [], // 核心业务逻辑
|
||||
'medium': [], // 工具函数
|
||||
'low': [] // 配置、测试
|
||||
};
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.filename.includes('core') || file.filename.includes('service')) {
|
||||
priority.high.push(file);
|
||||
} else if (file.filename.includes('util') || file.filename.includes('helper')) {
|
||||
priority.medium.push(file);
|
||||
} else {
|
||||
priority.low.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
return priority;
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 限制分析范围
|
||||
|
||||
对于大型 PR,限制分析范围:
|
||||
|
||||
```javascript
|
||||
const limitAnalysisScope = (files, maxFiles = 10, maxLines = 1000) => {
|
||||
let totalLines = 0;
|
||||
const selectedFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (selectedFiles.length >= maxFiles) break;
|
||||
if (totalLines + file.changes > maxLines) break;
|
||||
|
||||
selectedFiles.push(file);
|
||||
totalLines += file.changes;
|
||||
}
|
||||
|
||||
return selectedFiles;
|
||||
};
|
||||
```
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Q: 如何处理大型 PR?
|
||||
|
||||
**A**: 大型 PR(>1000 行)建议:
|
||||
1. 按模块分组分析
|
||||
2. 优先审查核心文件
|
||||
3. 分批生成审查报告
|
||||
4. 建议作者拆分为多个小 PR
|
||||
|
||||
### Q: 如何处理重命名文件?
|
||||
|
||||
**A**: GitLink 的 PR API 会正确处理重命名:
|
||||
- `status` 为 `renamed`
|
||||
- `patch` 包含重命名前后的完整路径
|
||||
- 分析时使用新文件名
|
||||
|
||||
### Q: 如何检测文件语言?
|
||||
|
||||
**A**: 使用文件扩展名检测:
|
||||
|
||||
```javascript
|
||||
const detectLanguage = (filename) => {
|
||||
const ext = filename.split('.').pop();
|
||||
const languageMap = {
|
||||
'go': 'go',
|
||||
'js': 'javascript',
|
||||
'ts': 'typescript',
|
||||
'py': 'python',
|
||||
'java': 'java',
|
||||
'rb': 'ruby',
|
||||
'php': 'php'
|
||||
};
|
||||
return languageMap[ext] || 'other';
|
||||
};
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [代码质量检查](code-review-quality.md) - 代码质量分析
|
||||
- [安全性检查](code-review-security.md) - 安全性分析
|
||||
- [性能检查](code-review-performance.md) - 性能分析
|
||||
- [完整工作流](../examples/comprehensive-review-workflow.md) - 完整审查流程
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-06-12*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue