diff --git a/README.md b/README.md index e1379906..98e3ef23 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ### Requirements -- Node.js 14+ (`npm`/`npx`) — for npm installation - Supported platforms: macOS, Linux, Windows (x64/arm64) - Go 1.26+ — only required for building from source @@ -54,10 +53,15 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans #### Install -**From npm (recommended):** +**一键安装(推荐) — 无需 npm、无需 Go:** + +```bash +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +**From npm:** ```bash -# One command: installs CLI binary + all 12 AI Agent Skills npm install -g @gitlink-ai/cli ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 0eb0fcdf..40bca579 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,7 +44,6 @@ ### 前置条件 -- Node.js 14+(`npm`/`npx`)— 用于 npm 安装 - 支持平台:macOS、Linux、Windows(x64/arm64) - Go 1.26+ — 仅从源码构建时需要 @@ -56,17 +55,16 @@ 选择以下**任一**方式: -**方式 1 — 从 npm 安装(推荐):** +**方式 1 — 一键安装(推荐,无需 npm、无需 Go):** + +```bash +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +**方式 2 — 从 npm 安装:** ```bash -# 安装 CLI npm install -g @gitlink-ai/cli - -# 安装 CLI Skill(必须,全平台通用) -gitlink-cli-install-skills - -# 也可使用 npx 安装 Skill -npx skills add ccfos/gitlink-cli/skills -y -g ``` **方式 2 — 从源码构建:** diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d9..20c09ee7 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -67,10 +67,10 @@ func runAPI(c *cobra.Command, args []string) error { 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, "") + errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) return output.Print(errEnv, resolveFormat()) } - return err + return fmt.Errorf("API 请求失败 [%s %s]: %w", method, path, err) } return output.Print(env, resolveFormat()) diff --git a/gitlink-cli.exe b/gitlink-cli.exe deleted file mode 100644 index efff36fd..00000000 Binary files a/gitlink-cli.exe and /dev/null differ diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..9295383c --- /dev/null +++ b/install.sh @@ -0,0 +1,196 @@ +#!/bin/bash +# GitLink CLI 一键安装脚本 +# 自动检测平台,下载预编译二进制,安装 skills +# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +set -e + +REPO_OWNER="Gitlink" +REPO_NAME="gitlink-cli" +BINARY_NAME="gitlink-cli" +API_BASE="https://www.gitlink.org.cn" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +SKILLS_DIR="${HOME}/.gitlink/skills" +VERSION="${VERSION:-latest}" + +# ---------- 颜色输出 ---------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +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} $*"; } + +# ---------- 平台检测 ---------- +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}" +} + +# ---------- 下载二进制 ---------- +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}" + + step "下载: ${archive_name}" + info "URL: ${download_url}" + + local tmpdir + tmpdir="$(mktemp -d)" + trap "rm -rf ${tmpdir}" EXIT + + curl -sSL --connect-timeout 10 --max-time 120 -o "${tmpdir}/${archive_name}" "${download_url}" + + if [ ! -s "${tmpdir}/${archive_name}" ]; then + error "下载失败,文件为空或不存在" + 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)" + + curl -sSL --connect-timeout 10 --max-time 60 -o "${tmpdir}/skills.tar.gz" "${skills_url}" 2>/dev/null || true + + if [ -s "${tmpdir}/skills.tar.gz" ]; 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 +} + +# ---------- main ---------- +main() { + echo "" + echo " ╔══════════════════════════════════════╗" + echo " ║ GitLink CLI 一键安装 ║" + echo " ╚══════════════════════════════════════╝" + echo "" + + 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 # 查看所有命令" + echo "" +} + +main "$@" diff --git a/internal/client/client.go b/internal/client/client.go index d5b2340b..7db76fbb 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,6 +11,7 @@ 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" ) @@ -25,6 +26,8 @@ type APIError struct { StatusCode int Code interface{} Message string + Kind clierrors.ErrorKind + Suggestion string } func (e *APIError) Error() string { @@ -101,10 +104,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, } } @@ -126,11 +132,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, } } } @@ -177,17 +185,43 @@ 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), } } diff --git a/internal/context/repo.go b/internal/context/repo.go index 0c9adfb0..03ab176b 100644 --- a/internal/context/repo.go +++ b/internal/context/repo.go @@ -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) } } diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 00000000..d239c9d1 --- /dev/null +++ b/internal/errors/errors.go @@ -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" +} diff --git a/npm/package.json b/npm/package.json index d4ab32af..3f37a7c4 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@gitlink-ai/cli", - "version": "0.1.13", + "version": "0.2.0", "description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化", "bin": { "gitlink-cli": "bin/cli.js", diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b64..3f516359 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -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) }, }, diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad39..eb146394 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -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) }, }, diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 056b5957..5fa9398e 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1,6 +1,8 @@ package common import ( + "fmt" + "os" "strconv" "github.com/spf13/cobra" @@ -26,33 +28,61 @@ 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 } + // 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 + } + } + } + return s.Run(ctx) }, } 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) } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9f..029c5ccb 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -1,13 +1,17 @@ package common import ( + "bufio" "encoding/json" "fmt" "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/context" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -16,6 +20,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 +37,16 @@ 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 } // 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 @@ -52,11 +59,12 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { } 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, }, nil } @@ -109,11 +117,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 +} diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index ea4e4420..429a12a9 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -78,6 +78,15 @@ var tagIDs = map[string]int{ "搁置": 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"` @@ -566,15 +575,16 @@ func parseTracker(label string) (int, error) { } } -// parseLabel converts a label name to its GitLink tag ID -func parseLabel(name string) (int, error) { - if id, ok := tagIDs[name]; ok && id != 0 { +// 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 tagIDs mapping", name) + return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name) } func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go index aecd9970..0c59c1d2 100644 --- a/shortcuts/issue/batch_create.go +++ b/shortcuts/issue/batch_create.go @@ -3,12 +3,76 @@ 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) + 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, 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", @@ -49,6 +113,11 @@ func runBatchCreate(ctx *common.RuntimeContext) error { 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"))) @@ -99,7 +168,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error { continue } - body := buildCreateBody(ctx, input, template) + body := buildCreateBody(ctx, input, template, tags) env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { result.Status = "failed" @@ -126,7 +195,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error { return nil } -func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string) map[string]interface{} { +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 { @@ -143,9 +212,9 @@ func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, templat if template != "" { body["description"] = buildTemplateDescription(input, template) if template == "bug" { - body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]} + body["issue_tag_ids"] = []interface{}{tags["缺陷"]} } else if template == "feature" { - body["issue_tag_ids"] = []interface{}{tagIDs["功能"]} + body["issue_tag_ids"] = []interface{}{tags["功能"]} } } else if input.Body != "" { body["description"] = input.Body @@ -157,7 +226,7 @@ func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, templat } } if input.Label != "" { - if tid, err := parseLabel(input.Label); err == nil { + if tid, err := parseLabel(input.Label, tags); err == nil { body["issue_tag_ids"] = []interface{}{tid} } } diff --git a/shortcuts/issue/batch_create_test.go b/shortcuts/issue/batch_create_test.go index 94253610..b91680ec 100644 --- a/shortcuts/issue/batch_create_test.go +++ b/shortcuts/issue/batch_create_test.go @@ -12,6 +12,15 @@ import ( "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 { @@ -25,6 +34,21 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { 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") @@ -57,6 +81,10 @@ func decodeReqBody(t *testing.T, r *http.Request) map[string]interface{} { 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() @@ -75,6 +103,8 @@ func TestBatchCreate_FromTitles(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++ body := decodeReqBody(t, r) @@ -114,6 +144,10 @@ func TestBatchCreate_FromTitles(t *testing.T) { 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() @@ -131,12 +165,15 @@ func TestBatchCreate_FromCSV(t *testing.T) { var created []map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues") { + 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)}) - return + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) })) defer server.Close() @@ -161,6 +198,10 @@ 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() @@ -175,6 +216,10 @@ 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() @@ -188,12 +233,19 @@ func TestBatchCreate_CSVOnlyHeader(t *testing.T) { func TestBatchCreate_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.StatusUnprocessableEntity) - return + 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) } - writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(callCount)}) })) defer server.Close() @@ -223,7 +275,7 @@ func intVal(v interface{}) int { 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, "") + body := buildCreateBody(ctx, input, "", testTags) if body["subject"] != "Test issue" { t.Fatalf("subject: got %v", body["subject"]) } @@ -248,7 +300,7 @@ func TestBuildCreateBody_BugTemplate(t *testing.T) { Expected: "正常登录", Actual: "报错 500", } - body := buildCreateBody(ctx, input, "bug") + body := buildCreateBody(ctx, input, "bug", testTags) if body["subject"] != "登录报错" { t.Fatalf("subject: got %v", body["subject"]) } @@ -266,8 +318,8 @@ func TestBuildCreateBody_BugTemplate(t *testing.T) { t.Fatal("bug template missing issue_tag_ids") } else { ids := rawTags.([]interface{}) - if intVal(ids[0]) != tagIDs["缺陷"] { - t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], tagIDs["缺陷"]) + if intVal(ids[0]) != testTags["缺陷"] { + t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"]) } } } @@ -279,7 +331,7 @@ func TestBuildCreateBody_FeatureTemplate(t *testing.T) { UserStory: "作为用户,我想搜索内容", Acceptance: "搜索结果正确显示", } - body := buildCreateBody(ctx, input, "feature") + body := buildCreateBody(ctx, input, "feature", testTags) desc, _ := body["description"].(string) if !strings.Contains(desc, "## 用户故事") { t.Fatal("feature description missing user story header") @@ -299,14 +351,14 @@ func TestBuildCreateBody_WithPriorityLabel(t *testing.T) { Priority: "high", Label: "缺陷", } - body := buildCreateBody(ctx, input, "") + 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]) != tagIDs["缺陷"] { - t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], tagIDs["缺陷"]) + 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") @@ -557,23 +609,23 @@ func TestParsePriorityInvalid(t *testing.T) { } func TestParseLabelValid(t *testing.T) { - id, err := parseLabel("缺陷") + id, err := parseLabel("缺陷", testTags) if err != nil { t.Fatalf("unexpected error: %v", err) } - if id == 0 { - t.Fatal("expected non-zero tag ID") + if id != testTags["缺陷"] { + t.Fatalf("got %d, want %d", id, testTags["缺陷"]) } } func TestParseLabelInvalid(t *testing.T) { - if _, err := parseLabel("不存在的标签"); err == nil { + if _, err := parseLabel("不存在的标签", testTags); err == nil { t.Fatal("expected error") } } func TestParseLabelNumeric(t *testing.T) { - id, err := parseLabel("999") + id, err := parseLabel("999", testTags) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -583,7 +635,7 @@ func TestParseLabelNumeric(t *testing.T) { } func TestLabelNamesReturnsAll(t *testing.T) { - names := labelNames() + names := labelNames(testTags) if !strings.Contains(names, "缺陷") { t.Fatal("missing 缺陷 in label names") } @@ -597,7 +649,7 @@ func TestLabelNamesReturnsAll(t *testing.T) { 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, "") + 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"]) } @@ -606,7 +658,7 @@ func TestBuildCreateBody_DefaultStatus(t *testing.T) { 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, "") + 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"]) } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index aa77ff58..e0efc6b7 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -27,6 +27,9 @@ func Shortcuts() []*common.Shortcut { newBatchAssignShortcut(), newBatchLabelShortcut(), newBatchCreateShortcut(), + newLabelAddShortcut(), + newLabelRemoveShortcut(), + newLabelListShortcut(), { Name: "list", Description: "List issues", @@ -47,7 +50,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { - return err + return fmt.Errorf("获取 Issue 列表失败: %w", err) } return ctx.Output(env) }, @@ -55,6 +58,11 @@ func Shortcuts() []*common.Shortcut { { 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"}, @@ -66,7 +74,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 } @@ -87,7 +95,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) }, @@ -102,13 +110,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) }, @@ -116,6 +124,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}, }, @@ -123,7 +136,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 } @@ -138,6 +151,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 } @@ -147,6 +191,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"}, @@ -157,7 +206,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 } @@ -192,7 +241,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) }, @@ -200,6 +249,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}, @@ -208,11 +262,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 } @@ -221,7 +275,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) }, @@ -232,7 +286,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 { diff --git a/shortcuts/issue/label.go b/shortcuts/issue/label.go new file mode 100644 index 00000000..bc5e279e --- /dev/null +++ b/shortcuts/issue/label.go @@ -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) + }, + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 00000000..23d55da7 --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -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) + }, + }, + } +} diff --git a/shortcuts/org/batch.go b/shortcuts/org/batch.go new file mode 100644 index 00000000..26c82893 --- /dev/null +++ b/shortcuts/org/batch.go @@ -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 +} diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299f..a330dc6f 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -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 } diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5f4a7135..951c71f1 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -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) }, diff --git a/shortcuts/pr/review.go b/shortcuts/pr/review.go new file mode 100644 index 00000000..61645b81 --- /dev/null +++ b/shortcuts/pr/review.go @@ -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) + }, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 9e8727ff..cd5515bb 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,44 +7,50 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "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" // 新增webhook管理 - "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" // 新增wiki管理 + "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(), - "wiki": wiki.Shortcuts(), // 新增wiki - "webhook": webhook.Shortcuts(), // 新增webhook + "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(), + "team": team.Shortcuts(), + "wiki": wiki.Shortcuts(), + "webhook": webhook.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", - "wiki": "Wiki operations", // 新增wiki - "webhook": "Webhook operations", // 新增webhook + "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", + "team": "Team operations", + "wiki": "Wiki operations", + "webhook": "Webhook operations", } for name, shortcuts := range groups { diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc5..5da63076 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -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) + }, + }, } } diff --git a/shortcuts/repo/batch_member.go b/shortcuts/repo/batch_member.go new file mode 100644 index 00000000..7d4c0d7d --- /dev/null +++ b/shortcuts/repo/batch_member.go @@ -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 +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 1fa9d294..761efb53 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -3,14 +3,15 @@ package repo import ( "fmt" "net/url" + "strconv" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ - newBatchCreateShortcut(), - newBatchUpdateShortcut(), + shortcuts := []*common.Shortcut{ + newBatchCreateShortcut(), + newBatchUpdateShortcut(), { Name: "list", Description: "List repositories for a user or organization", @@ -35,7 +36,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) }, @@ -49,7 +50,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) }, @@ -57,20 +58,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) @@ -91,7 +97,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) }, @@ -99,13 +105,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) }, @@ -113,16 +126,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 } diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 443dec3b..25cde0ae 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/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 } diff --git a/shortcuts/team/team.go b/shortcuts/team/team.go new file mode 100644 index 00000000..18f4de43 --- /dev/null +++ b/shortcuts/team/team.go @@ -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) + }, + }, + } +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 506986c5..4971cc2c 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -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,14 +26,14 @@ 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 } // Use relative path to avoid URL construction issues 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) }, diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index ebeb5e22..10fcbe59 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -72,7 +72,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q) if err != nil { - return err + return fmt.Errorf("获取 Webhook 列表失败: %w", err) } return ctx.Output(env) }, @@ -92,7 +92,7 @@ func Shortcuts() []*common.Shortcut { return err } - webhookURL, err := ctx.RequireArg("url") + webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook") if err != nil { return err } @@ -125,7 +125,7 @@ func Shortcuts() []*common.Shortcut { env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload) if err != nil { - return err + return fmt.Errorf("创建 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -147,7 +147,7 @@ func Shortcuts() []*common.Shortcut { return err } - webhookID, err := ctx.RequireArg("id") + webhookID, err := ctx.RequireArg("id", "--id 1") if err != nil { return err } @@ -163,7 +163,7 @@ func Shortcuts() []*common.Shortcut { if webhookURL == "" { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) if err != nil { - return fmt.Errorf("failed to get current webhook info: %w", err) + return fmt.Errorf("获取 Webhook 当前信息失败: %w", err) } webhookData, ok := getEnv.Data.(map[string]interface{}) if !ok { @@ -201,7 +201,7 @@ func Shortcuts() []*common.Shortcut { env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload) if err != nil { - return err + return fmt.Errorf("更新 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -217,7 +217,7 @@ func Shortcuts() []*common.Shortcut { return err } - webhookID, err := ctx.RequireArg("id") + webhookID, err := ctx.RequireArg("id", "--id 1") if err != nil { return err } @@ -232,7 +232,7 @@ func Shortcuts() []*common.Shortcut { "message": "Webhook deleted successfully", }, nil)) } - return delErr + return fmt.Errorf("删除 Webhook 失败: %w", delErr) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "Webhook deleted successfully", @@ -251,7 +251,7 @@ func Shortcuts() []*common.Shortcut { return err } - webhookID, err := ctx.RequireArg("id") + webhookID, err := ctx.RequireArg("id", "--id 1") if err != nil { return err } @@ -263,7 +263,7 @@ func Shortcuts() []*common.Shortcut { env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil) if err != nil { - return err + return fmt.Errorf("测试 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -279,14 +279,14 @@ func Shortcuts() []*common.Shortcut { return err } - webhookID, err := ctx.RequireArg("id") + 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 err + return fmt.Errorf("查看 Webhook 详情失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/wiki/CHANGELOG.md b/shortcuts/wiki/CHANGELOG.md new file mode 100644 index 00000000..c604c341 --- /dev/null +++ b/shortcuts/wiki/CHANGELOG.md @@ -0,0 +1,82 @@ +# wiki 模块变更日志 + +## 2026-05-31 新增 `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` diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 914f57f5..31777eb3 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -3,10 +3,14 @@ package wiki import ( "encoding/base64" "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" @@ -140,7 +144,7 @@ func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (s q.Set("pageName", pageName) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) if err != nil { - return "", err + return "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err) } data, ok := env.Data.(map[string]interface{}) if !ok { @@ -204,6 +208,233 @@ func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) 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{ { @@ -223,7 +454,7 @@ func Shortcuts() []*common.Shortcut { q.Set("projectId", projectID) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q) if err != nil { - return err + return fmt.Errorf("获取 Wiki 页面列表失败: %w", err) } cleanWikiList(env) return ctx.Output(env) @@ -239,7 +470,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -254,7 +485,7 @@ func Shortcuts() []*common.Shortcut { q.Set("pageName", title) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) if err != nil { - return err + return fmt.Errorf("查看 Wiki 页面失败: %w", err) } return outputWithDecodedContent(ctx, env) }, @@ -262,6 +493,11 @@ func Shortcuts() []*common.Shortcut { { 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)"}, @@ -272,7 +508,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -300,7 +536,7 @@ func Shortcuts() []*common.Shortcut { env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body) if err != nil { - return err + return fmt.Errorf("创建 Wiki 页面失败: %w", err) } return ctx.Output(env) }, @@ -308,6 +544,11 @@ func Shortcuts() []*common.Shortcut { { 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}, @@ -320,7 +561,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -373,7 +614,7 @@ func Shortcuts() []*common.Shortcut { env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body) if err != nil { - return err + return fmt.Errorf("更新 Wiki 页面失败: %w", err) } return ctx.Output(env) }, @@ -381,6 +622,11 @@ func Shortcuts() []*common.Shortcut { { 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}, }, @@ -388,7 +634,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -418,12 +664,20 @@ func Shortcuts() []*common.Shortcut { "message": "Wiki page deleted successfully", }) } - return delErr + return fmt.Errorf("删除 Wiki 页面失败: %w", delErr) } return ctx.OutputData(map[string]string{ "message": "Wiki page deleted successfully", }) }, }, + { + 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, + }, } } diff --git a/skills/README.md b/skills/README.md index 2786e1d3..e97e4762 100644 --- a/skills/README.md +++ b/skills/README.md @@ -106,6 +106,8 @@ skills/ │ ├── SKILL.md # CI 操作指南 │ └── examples/ │ └── ci-workflow.md # CI 工作流 +├── gitlink-wiki/ # Wiki 管理 +│ └── SKILL.md # Wiki 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 └── gitlink-workflow/ # AI 自动化工作流 @@ -135,6 +137,7 @@ 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 | @@ -210,6 +213,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 +322,7 @@ AI 代理可以: - ✅ 自动创建和管理 Issue - ✅ 自动创建和合并 PR - ✅ 自动发布 Release +- ✅ 自动管理 Wiki 文档 - ✅ 自动分类 Issue - ✅ 自动生成 Release Notes - ✅ 自动执行代码审查 diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md index 4fc43069..eec50282 100644 --- a/skills/gitlink-wiki/SKILL.md +++ b/skills/gitlink-wiki/SKILL.md @@ -1,24 +1,20 @@ --- name: gitlink-wiki version: 1.0.0 -description: "Wiki 页面管理:列出、查看、创建、更新、删除 Wiki 页面。当用户需要管理项目 Wiki 文档时触发。" +description: "Wiki 管理:查看、创建、更新、删除 Wiki 页面。当用户需要操作 GitLink Wiki 时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli wiki --help" --- -# gitlink-wiki(Wiki 页面操作) +# gitlink-wiki(Wiki 操作) **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) 了解认证和全局参数。 - -## 功能概述 - -GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创建、编辑和管理。Wiki 功能通过 GitLink Gateway API (`https://gateway.gitlink.org.cn`) 提供,与核心 GitLink API 使用不同的 Base URL。 +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) ## Shortcuts @@ -26,9 +22,9 @@ GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创 |----------|------|----------| | `wiki +list` | 列出所有 Wiki 页面 | 否(公开项目) | | `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) | -| `wiki +create` | 创建 Wiki 页面 | 是 | -| `wiki +update` | 更新 Wiki 页面 | 是 | -| `wiki +delete` | 删除 Wiki 页面 | 是 | +| `wiki +create` | 创建 Wiki 页面,支持 `--dry-run` 预览 | 是 | +| `wiki +update` | 更新 Wiki 页面,支持 `--dry-run` 预览 | 是 | +| `wiki +delete` | 删除 Wiki 页面,支持 `--dry-run` 预览 | 是 | ## 使用示例 @@ -36,217 +32,50 @@ GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创 # 列出所有 Wiki 页面 gitlink-cli wiki +list --owner Gitlink --repo forgeplus -# 查看 Wiki 页面 -gitlink-cli wiki +view --title "Home" +# 查看 Wiki 页面内容(自动解码 base64 并输出 content_decoded 字段) +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home" -# 创建 Wiki 页面(使用 --content 提供内容) -gitlink-cli wiki +create --title "API Reference" --content "# API Reference\n\n..." +# 创建 Wiki 页面(从命令行内容) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "设计文档" --content "# 架构设计\n\n## 概述\n..." -# 创建 Wiki 页面(从文件读取) -gitlink-cli wiki +create --title "Getting Started" --file README.md +# 创建 Wiki 页面(从文件) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api-docs.md + +# 预览创建操作(不实际执行) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "测试页面" --content "test" --dry-run # 更新 Wiki 页面(覆盖整个内容) -gitlink-cli wiki +update --title "Home" --cover "# Updated content" +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "# 新内容\n..." # 更新 Wiki 页面(追加内容) -gitlink-cli wiki +update --title "Home" --add "\n\n## New Section\n\nAdditional content" +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "\n\n## 新增章节\n..." + +# 更新 Wiki 页面(从文件追加) +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --file ./new-section.md --add + +# 重命名 Wiki 页面(--page 指定当前标题,--title 指定新标题) +gitlink-cli wiki +update --owner myuser --repo myrepo --page "旧标题" --title "新标题" + +# 预览更新操作 +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "新内容" --dry-run # 删除 Wiki 页面 -gitlink-cli wiki +delete --title "Old Page" +gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" + +# 预览删除操作 +gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run ``` -## Wiki 内容编码 +## Wiki 页面内容格式 -所有 Wiki 内容都会自动进行 **Base64 编码**: +- Wiki 内容以 **base64** 编码传输,CLI 已自动处理编码/解码 +- `wiki +view` 返回结果中包含 `content_decoded` 字段(原始文本内容) +- `--content` 和 `--file` 参数接受原始文本,CLI 会自动 base64 编码后发送 -- **创建页面**:`--content` 或 `--file` 提供的内容会自动 Base64 编码 -- **更新页面**:`--cover` 或 `--add` 提供的内容会自动 Base64 编码 -- **查看页面**:返回的 `content_base64` 字段需要解码,CLI 会自动提供 `content_decoded` 字段 +## API 注意事项 -**无需手动编码/解码** — CLI 自动处理。 - -## 更新模式 - -`wiki +update` 支持两种更新模式: - -### 1. 覆盖模式 (`--cover`) -完全替换页面内容: - -```bash -gitlink-cli wiki +update --title "Home" --cover "# New Content" -``` - -### 2. 追加模式 (`--add`) -在现有内容基础上追加: - -```bash -gitlink-cli wiki +update --title "Home" --add "\n\n## Additional Section" -``` - -**工作原理**: -1. CLI 先获取当前页面内容 -2. 将新内容追加到现有内容后 -3. 提交更新后的完整内容 - -### 3. 重命名 + 更新 -```bash -# 将 "Old-Title" 重命名为 "New-Title" 并更新内容 -gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated content" -``` - -## 参数说明 - -### 通用参数 - -| 参数 | 说明 | -|------|------| -| `--owner` | 仓库所有者(自动从 git remote 解析) | -| `--repo` | 仓库名称(自动从 git remote 解析) | -| `--format` | 输出格式: `json`/`table`/`yaml` | -| `--debug` | 开启调试输出 | - -### Wiki 专用参数 - -| 参数 | Short | 说明 | 适用于 | -|------|-------|------|--------| -| `--title` | `-t` | Wiki 页面标题 | `+view`, `+create`, `+update`, `+delete` | -| `--content` | `-c` | Wiki 页面内容(纯文本) | `+create` | -| `--file` | `-f` | 从文件读取内容 | `+create`, `+update` | -| `--page` | `-p` | 当前页面标题(用于查找和重命名) | `+update` | -| `--cover` | `-c` | 覆盖整个页面内容 | `+update` | -| `--add` | `-a` | 追加内容到现有页面 | `+update` | -| `--message` | `-m` | 提交消息 | `+create`, `+update` | - -## API 架构 - -### Base URL 差异 - -**重要**:Wiki 功能使用不同的 API Base URL: - -| 模块 | Base URL | -|------|----------| -| 核心 API (repo, issue, pr) | `https://www.gitlink.org.cn/api` | -| Wiki API | `https://gateway.gitlink.org.cn/api` | - -### Gateway API 响应格式 - -Wiki API 返回的响应格式需要特殊处理: - -```json -{ - "code": 200, - "msg": "success", - "data": { ... } -} -``` - -CLI 自动解析 Gateway 响应并提取 `data` 字段。 - -## 项目 ID 解析 - -Wiki 操作需要 `project_id`(数据库内部 ID),而不仅仅是 `owner/repo`。 - -**CLI 自动处理**: -1. 首先调用 `/api/:owner/:repo/detail` 获取 `project_id` -2. 使用 `project_id` 调用 Wiki Gateway API -3. 缓存 `project_id` 避免重复请求 - -**无需手动获取 project_id** — CLI 自动完成。 - -## 常见问题 - -### Q: 为什么 Wiki API 返回 404? - -**A:** 可能原因: -1. 页面标题不区分大小写,但必须完全匹配 -2. 项目没有启用 Wiki 功能 -3. 权限不足(私有项目需要认证) - -### Q: 更新操作失败怎么办? - -**A:** 检查: -1. `--title` 是否指定了正确的目标页面标题 -2. 如果是重命名,`--page` 是否指定了当前页面标题 -3. 是否有足够的权限修改 Wiki - -### Q: 如何创建多级 Wiki 页面? - -**A:** GitLink Wiki 不支持真正的目录结构,但可以通过命名约定模拟: -``` -"API/Authentication" # 使用斜杠 -"API/Authorization" # 模拟层级 -"Getting-Started" # 使用连字符 -``` - -### Q: 支持 Markdown 哪些语法? - -**A:** GitLink Wiki 支持 CommonMark 标准的 Markdown,包括: -- 标题 (`#`, `##`, `###`) -- 列表(有序、无序) -- 代码块(```) -- 链接 (`[text](url)`) -- 图片 (`![alt](url)`) -- 表格 -- 粗体、斜体 - -### Q: 删除操作为什么不返回确认信息? - -**A:** GitLink Wiki Gateway API 的删除端点可能返回成功或不返回信息。CLI 使用"获取页面内容"验证删除是否成功: -- 如果页面已不存在,删除成功 -- 如果页面仍存在,返回删除错误 - -## AI Agent 使用指南 - -### 推荐工作流 - -当用户请求"创建项目文档"时: - -1. **确认**项目 Wiki 需求(页面标题、内容) -2. **检查**是否需要创建多个页面 -3. **执行** `wiki +create` 创建页面 -4. **报告**创建结果和页面访问链接 - -当用户请求"更新文档"时: - -1. **获取**当前页面内容 (`wiki +view`) -2. **确认**更新方式(覆盖 vs 追加) -3. **执行** `wiki +update` -4. **报告**更新结果 - -### 错误处理 - -遇到以下错误时的建议: - -| 错误 | 原因 | 解决方案 | -|------|------|----------| -| `404 Not Found` | 页面不存在 | 使用 `wiki +list` 查看可用页面 | -| `403 Forbidden` | 权限不足 | 引导用户登录 (`gitlink-cli auth login`) | -| `project_id not found` | 项目不存在或无权限 | 检查 `--owner` 和 `--repo` 是否正确 | -| `failed to encode/decode` | Base64 编解码问题 | 检查内容是否为有效 UTF-8 文本 | - -## Raw API 补充 - -```bash -# 直接调用 Gateway API(不推荐,优先使用 Shortcuts) -curl -X GET "https://gateway.gitlink.org.cn/api/wiki/open/wikiPages?owner=Gitlink&repo=forgeplus&projectId=123" - -# 创建 Wiki 页面(需要手动 Base64 编码) -curl -X POST "https://gateway.gitlink.org.cn/api/wiki/open/createWiki" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"owner":"Gitlink","repo":"forgeplus","projectId":123,"pageName":"Test","title":"Test","content_base64":"..."}' -``` - -## 参考文档 - -- [wiki +list](references/wiki-list.md) — 列出 Wiki 页面 -- [wiki +view](references/wiki-view.md) — 查看 Wiki 页面 -- [wiki +create](references/wiki-create.md) — 创建 Wiki 页面 -- [wiki +update](references/wiki-update.md) — 更新 Wiki 页面 -- [wiki +delete](references/wiki-delete.md) — 删除 Wiki 页面 -- [完整工作流](examples/wiki-workflow.md) — 实战示例 - -## 相关链接 - -- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则 -- [gitlink-repo](../gitlink-repo/SKILL.md) — 仓库管理 -- [GitLink Wiki 帮助](https://help.gitlink.org.cn/) +- Wiki 使用**独立的 Gateway API**(`https://gateway.gitlink.org.cn/api`),不走主 API +- 所有 Wiki 操作需要先解析 `project_id`(通过主 API 的 `/{owner}/{repo}/detail` 获取) +- `project_id` 会在当前会话中缓存,避免重复请求 +- `wiki +update` 的 `--page` 参数用于指定要查找的页面标题(不指定时默认等于 `--title`) +- `wiki +delete` 有容错逻辑:如果删除接口返回错误,会二次验证页面是否已不存在