This commit is contained in:
狗gogo 2026-06-04 12:34:56 +08:00
commit 0be1db712a
35 changed files with 2547 additions and 422 deletions

View File

@ -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
```

View File

@ -44,7 +44,6 @@
### 前置条件
- Node.js 14+`npm`/`npx`)— 用于 npm 安装
- 支持平台macOS、Linux、Windowsx64/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 — 从源码构建:**

View File

@ -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())

Binary file not shown.

196
install.sh Executable file
View File

@ -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 "$@"

View File

@ -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),
}
}

View File

@ -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)
}
}

115
internal/errors/errors.go Normal file
View File

@ -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"
}

View File

@ -1,6 +1,6 @@
{
"name": "@gitlink-ai/cli",
"version": "0.1.13",
"version": "0.2.0",
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
"bin": {
"gitlink-cli": "bin/cli.js",

View File

@ -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)
},
},

View File

@ -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)
},
},

View File

@ -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)
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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}
}
}

View File

@ -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"])
}

View File

@ -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 {

77
shortcuts/issue/label.go Normal file
View File

@ -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)
},
}
}

View File

@ -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)
},
},
}
}

147
shortcuts/org/batch.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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)
},

83
shortcuts/pr/review.go Normal file
View File

@ -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)
},
}
}

View File

@ -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 {

View File

@ -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)
},
},
}
}

View File

@ -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或 --fromCSV文件路径")
}
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
}

View File

@ -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
}

View File

@ -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
}

137
shortcuts/team/team.go Normal file
View File

@ -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)
},
},
}
}

View File

@ -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)
},

View File

@ -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)
},

View File

@ -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` — 正则提取外部图片 URLHEAD 请求检查可达性(超时 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`

View File

@ -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,
},
}
}

View File

@ -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
- ✅ 自动执行代码审查

View File

@ -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-wikiWiki 页面操作)
# gitlink-wikiWiki 操作)
**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` 有容错逻辑:如果删除接口返回错误,会二次验证页面是否已不存在