forked from Gitlink/gitlink-cli
Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli
This commit is contained in:
commit
0be1db712a
10
README.md
10
README.md
|
|
@ -44,7 +44,6 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
### Requirements
|
||||
|
||||
- Node.js 14+ (`npm`/`npx`) — for npm installation
|
||||
- Supported platforms: macOS, Linux, Windows (x64/arm64)
|
||||
- Go 1.26+ — only required for building from source
|
||||
|
||||
|
|
@ -54,10 +53,15 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
#### Install
|
||||
|
||||
**From npm (recommended):**
|
||||
**一键安装(推荐) — 无需 npm、无需 Go:**
|
||||
|
||||
```bash
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
```
|
||||
|
||||
**From npm:**
|
||||
|
||||
```bash
|
||||
# One command: installs CLI binary + all 12 AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@
|
|||
|
||||
### 前置条件
|
||||
|
||||
- Node.js 14+(`npm`/`npx`)— 用于 npm 安装
|
||||
- 支持平台:macOS、Linux、Windows(x64/arm64)
|
||||
- Go 1.26+ — 仅从源码构建时需要
|
||||
|
||||
|
|
@ -56,17 +55,16 @@
|
|||
|
||||
选择以下**任一**方式:
|
||||
|
||||
**方式 1 — 从 npm 安装(推荐):**
|
||||
**方式 1 — 一键安装(推荐,无需 npm、无需 Go):**
|
||||
|
||||
```bash
|
||||
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
|
||||
```
|
||||
|
||||
**方式 2 — 从 npm 安装:**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# 安装 CLI Skill(必须,全平台通用)
|
||||
gitlink-cli-install-skills
|
||||
|
||||
# 也可使用 npx 安装 Skill
|
||||
npx skills add ccfos/gitlink-cli/skills -y -g
|
||||
```
|
||||
|
||||
**方式 2 — 从源码构建:**
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
BIN
gitlink-cli.exe
BIN
gitlink-cli.exe
Binary file not shown.
|
|
@ -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 "$@"
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@gitlink-ai/cli",
|
||||
"version": "0.1.13",
|
||||
"version": "0.2.0",
|
||||
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
|
||||
"bin": {
|
||||
"gitlink-cli": "bin/cli.js",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取分支列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -33,6 +33,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
}
|
||||
return fmt.Sprintf("Create branch: %s (from %s)", name, from), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
||||
|
|
@ -41,7 +50,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
|
|
@ -52,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -60,6 +72,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Delete branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -67,13 +84,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("删除分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -81,6 +101,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Protect branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -88,13 +113,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("设置分支保护失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -102,6 +130,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Unprotect branch: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -109,11 +142,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
name, err := ctx.RequireArg("name", "--name feature/new-thing")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("取消分支保护失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 CI 构建列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -42,7 +42,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage := ctx.Arg("stage")
|
||||
step := ctx.Arg("step")
|
||||
if stage == "" {
|
||||
|
|
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取构建日志失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -61,6 +64,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "restart",
|
||||
Description: "Restart a build",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
build := ctx.Arg("build")
|
||||
return fmt.Sprintf("Restart build #%s", build), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -68,17 +76,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重启构建失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop a build",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
build := ctx.Arg("build")
|
||||
return fmt.Sprintf("Stop build #%s", build), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -86,11 +102,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build", "--build 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("停止构建失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newLabelAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-add",
|
||||
Description: "Add labels to an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
{Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
labelsStr, _ := ctx.RequireArg("labels", `--labels "bug,urgent"`)
|
||||
body := map[string]interface{}{
|
||||
"labels": labelsStr,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLabelRemoveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-remove",
|
||||
Description: "Remove a label from an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
{Name: "label", Short: "l", Usage: "Label ID to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
label, _ := ctx.RequireArg("label", "--label bug")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLabelListShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "label-list",
|
||||
Description: "List labels on an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, _ := ctx.RequireArg("number", "--number 42")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List milestones",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name", `--name "My Name"`)
|
||||
body := map[string]interface{}{
|
||||
"title": name,
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if due := ctx.Arg("due"); due != "" {
|
||||
body["due_date"] = due
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New name"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["title"] = n
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if due := ctx.Arg("due"); due != "" {
|
||||
body["due_date"] = due
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --description, --due is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// BatchShortcuts 返回组织级批量成员管理 Shortcut
|
||||
func BatchShortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "batch-invite",
|
||||
Description: "Batch invite members to a project (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runOrgBatchInvite,
|
||||
},
|
||||
{
|
||||
Name: "batch-remove",
|
||||
Description: "Batch remove members from a project (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runOrgBatchRemove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runOrgBatchInvite 组织级批量邀请
|
||||
func runOrgBatchInvite(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseBatchUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
return inviteToOrgProjects(ctx, userIDs, dryRun)
|
||||
}
|
||||
|
||||
// runOrgBatchRemove 组织级批量移除
|
||||
func runOrgBatchRemove(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseBatchUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
return removeFromOrgProjects(ctx, userIDs, dryRun)
|
||||
}
|
||||
|
||||
// parseBatchUserIDs 从 --users 或 --from CSV 解析用户 ID 列表
|
||||
func parseBatchUserIDs(ctx *common.RuntimeContext) ([]int, error) {
|
||||
usersStr := ctx.Arg("users")
|
||||
csvFile := ctx.Arg("from")
|
||||
|
||||
if usersStr == "" && csvFile == "" {
|
||||
return nil, fmt.Errorf("must specify --users (comma-separated user IDs) or --from (CSV file path)")
|
||||
}
|
||||
|
||||
var userIDs []int
|
||||
|
||||
if usersStr != "" {
|
||||
ids, err := parseUserIDList(usersStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, ids...)
|
||||
}
|
||||
|
||||
if csvFile != "" {
|
||||
ids, err := parseCSVUserIDs(csvFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, ids...)
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
// parseCSVUserIDs 从 CSV 文件读取 user_id 列
|
||||
func parseCSVUserIDs(filePath string) ([]int, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open CSV file %s: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CSV file: %w", err)
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
// 查找 user_id 列
|
||||
header := records[0]
|
||||
colIdx := -1
|
||||
for i, h := range header {
|
||||
if strings.TrimSpace(h) == "user_id" {
|
||||
colIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if colIdx == -1 {
|
||||
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
|
||||
}
|
||||
|
||||
var ids []int
|
||||
for _, row := range records[1:] {
|
||||
if len(row) <= colIdx {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(row[colIdx])
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ package org
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
shortcuts := []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
|
|
@ -22,7 +24,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -34,11 +36,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id my-org")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看组织失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -51,13 +56,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id my-org")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织成员失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -65,12 +73,20 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create an organization",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
return fmt.Sprintf("Create organization: %s", name), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name", `--name "My Organization"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
|
|
@ -78,11 +94,222 @@ func Shortcuts() []*common.Shortcut {
|
|||
payload["description"] = d
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建组织失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New name"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["name"] = n
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
body["description"] = d
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --description is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite",
|
||||
Description: "Invite a member to a project",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
userID := ctx.Arg("user-id")
|
||||
owner := ctx.Arg("owner")
|
||||
repo := ctx.Arg("repo")
|
||||
return fmt.Sprintf("Invite user %s to %s/%s", userID, owner, repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
|
||||
return inviteToOrgProjects(ctx, []int{uid}, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-member",
|
||||
Description: "Remove a member from a project",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
userID := ctx.Arg("user-id")
|
||||
owner := ctx.Arg("owner")
|
||||
repo := ctx.Arg("repo")
|
||||
return fmt.Sprintf("Remove user %s from %s/%s", userID, owner, repo), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
|
||||
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
userID, err := ctx.RequireArg("user-id", "--user-id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
|
||||
}
|
||||
|
||||
return removeFromOrgProjects(ctx, []int{uid}, false)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 合并批量成员管理命令
|
||||
shortcuts = append(shortcuts, BatchShortcuts()...)
|
||||
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
// inviteToOrgProjects 向指定项目邀请用户
|
||||
func inviteToOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
|
||||
owner, repo, err := resolveOrgProject(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "invite",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", owner, repo), body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "invite",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// removeFromOrgProjects 从指定项目移除用户
|
||||
func removeFromOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
|
||||
owner, repo, err := resolveOrgProject(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "remove",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", owner, repo), body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", owner, repo),
|
||||
"action": "remove",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// resolveOrgProject 从 --owner/--repo 解析目标项目
|
||||
func resolveOrgProject(ctx *common.RuntimeContext) (owner, repo string, err error) {
|
||||
owner = ctx.Arg("owner")
|
||||
repo = ctx.Arg("repo")
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", fmt.Errorf("must specify --owner and --repo (e.g., --owner zzx-coder --repo gitlink-cli)")
|
||||
}
|
||||
return owner, repo, nil
|
||||
}
|
||||
|
||||
// parseUserIDList 解析逗号分隔的用户ID字符串
|
||||
func parseUserIDList(input string) ([]int, error) {
|
||||
var ids []int
|
||||
for _, s := range strings.Split(input, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %s", s)
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newApproveShortcut(),
|
||||
newRequestChangesShortcut(),
|
||||
newReviewsShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List pull requests",
|
||||
|
|
@ -30,7 +33,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -38,6 +41,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
title := ctx.Arg("title")
|
||||
head := ctx.Arg("head")
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
}
|
||||
return fmt.Sprintf("Create PR: %s (%s -> %s)", title, head, base), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "PR description"},
|
||||
|
|
@ -48,8 +61,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
title, err := ctx.RequireArg("title", `--title "Fix login crash"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
head, err := ctx.RequireArg("head", `--head feat/new-login`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
|
|
@ -64,7 +83,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -79,17 +98,29 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
return fmt.Sprintf("Merge PR #%s (method: %s)", id, method), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
|
|
@ -98,7 +129,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
|
|
@ -108,7 +142,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("合并 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -116,6 +150,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "close",
|
||||
Description: "Close a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Close PR #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -123,11 +162,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -141,11 +183,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 文件列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -159,17 +204,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to a pull request",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Add comment to PR #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -178,12 +231,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
body, _ := ctx.RequireArg("body")
|
||||
id, err := ctx.RequireArg("id", "--id 42")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body", `--body "Looks good to me"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch PR: %w", err)
|
||||
return fmt.Errorf("获取 PR 信息失败: %w", err)
|
||||
}
|
||||
issueID, err := extractIssueID(prEnv)
|
||||
if err != nil {
|
||||
|
|
@ -195,7 +254,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newApproveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "approve",
|
||||
Description: "Approve a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
body := map[string]interface{}{
|
||||
"state": "approved",
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
body["body"] = b
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestChangesShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "request-changes",
|
||||
Description: "Request changes on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
body, _ := ctx.RequireArg("body", `--body "Looks good"`)
|
||||
payload := map[string]interface{}{
|
||||
"state": "changes_requested",
|
||||
"body": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newReviewsShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "reviews",
|
||||
Description: "List reviews for a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id 42")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 Release 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -34,6 +34,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a release",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
name := ctx.Arg("name")
|
||||
tag := ctx.Arg("tag")
|
||||
return fmt.Sprintf("Create release: %s (tag: %s)", name, tag), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
||||
|
|
@ -45,8 +51,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, _ := ctx.RequireArg("tag")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
tag, err := ctx.RequireArg("tag", "--tag v1.0.0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name", `--name "Version 1.0.0"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"tag_name": tag,
|
||||
"name": name,
|
||||
|
|
@ -62,7 +74,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 Release 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -77,17 +89,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 Release 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a release",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
id := ctx.Arg("id")
|
||||
return fmt.Sprintf("Delete release #%s", id), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
|
|
@ -95,25 +115,58 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id", "--id 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if delErr != nil {
|
||||
// GitLink API bug: delete succeeds but returns error status.
|
||||
// Verify by checking if the release still exists.
|
||||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if viewErr != nil {
|
||||
// Release no longer exists — delete actually succeeded
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
return fmt.Errorf("删除 Release 失败: %w", delErr)
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a release",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New release name"},
|
||||
{Name: "body", Short: "b", Usage: "New release notes"},
|
||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id", "--id my-org")
|
||||
body := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
body["name"] = n
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
body["body"] = b
|
||||
}
|
||||
if p := ctx.Arg("prerelease"); p != "" {
|
||||
body["prerelease"] = p == "true"
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --body, --prerelease is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// BatchMemberShortcuts 返回批量成员管理相关的 Shortcut(由 repo.Shortcuts() 调用合并)
|
||||
func BatchMemberShortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "batch-invite",
|
||||
Description: "Batch invite members to a repository (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runBatchInvite,
|
||||
},
|
||||
{
|
||||
Name: "batch-remove",
|
||||
Description: "Batch remove members from a repository (from --users list or --from CSV file)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
|
||||
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
|
||||
},
|
||||
Run: runBatchRemove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runBatchInvite 执行批量邀请
|
||||
func runBatchInvite(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "invite",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "invite",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// runBatchRemove 执行批量移除
|
||||
func runBatchRemove(ctx *common.RuntimeContext) error {
|
||||
userIDs, err := parseUserIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if dryRun {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "remove",
|
||||
"status": "would execute (dry-run)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"user_id": uid}
|
||||
_, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
|
||||
status := "success"
|
||||
msg := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
msg = err.Error()
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": uid,
|
||||
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"action": "remove",
|
||||
"status": status,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.OutputData(results)
|
||||
}
|
||||
|
||||
// parseUserIDs 从 --users 或 --from 参数中解析用户 ID 列表
|
||||
func parseUserIDs(ctx *common.RuntimeContext) ([]int, error) {
|
||||
usersStr := ctx.Arg("users")
|
||||
csvFile := ctx.Arg("from")
|
||||
|
||||
if usersStr == "" && csvFile == "" {
|
||||
return nil, fmt.Errorf("必须指定 --users(逗号分隔的用户ID)或 --from(CSV文件路径)")
|
||||
}
|
||||
|
||||
var userIDs []int
|
||||
|
||||
if usersStr != "" {
|
||||
for _, s := range strings.Split(usersStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %s", s)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
}
|
||||
|
||||
if csvFile != "" {
|
||||
f, err := os.Open(csvFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open CSV file %s: %w", csvFile, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CSV file: %w", err)
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
// 查找 user_id 列
|
||||
header := records[0]
|
||||
colIdx := -1
|
||||
for i, h := range header {
|
||||
if strings.TrimSpace(h) == "user_id" {
|
||||
colIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if colIdx == -1 {
|
||||
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
|
||||
}
|
||||
|
||||
for _, row := range records[1:] {
|
||||
if len(row) <= colIdx {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(row[colIdx])
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return nil, fmt.Errorf("no valid user IDs found")
|
||||
}
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword", "--keyword my-project")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("搜索仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -38,12 +42,39 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword", "--keyword zhangsan")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("搜索用户失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "issues",
|
||||
Description: "Search issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
keyword, _ := ctx.RequireArg("keyword", "--keyword myproject")
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
package team
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List teams in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", org), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a team in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Team name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Team description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
name, _ := ctx.RequireArg("name", `--name "My Team"`)
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "members",
|
||||
Description: "List members of a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
id, _ := ctx.RequireArg("id", "--id 1")
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams/%s/members", org, id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "member-add",
|
||||
Description: "Add a user to a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
{Name: "user", Short: "u", Usage: "User login or ID to add", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
team, _ := ctx.RequireArg("team", "--team dev-team")
|
||||
user, _ := ctx.RequireArg("user", "--user alice")
|
||||
body := map[string]interface{}{
|
||||
"user_id": user,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "member-remove",
|
||||
Description: "Remove a user from a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
{Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org", "--org my-org")
|
||||
team, _ := ctx.RequireArg("team", "--team dev-team")
|
||||
user, _ := ctx.RequireArg("user", "--user alice")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -26,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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
# wiki 模块变更日志
|
||||
|
||||
## 2026-05-31 新增 `wiki +lint` 文档质量检查命令
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 在仓库目录下,owner/repo 自动从 git remote 解析
|
||||
gitlink-cli wiki +lint
|
||||
|
||||
# 只运行指定检查项(逗号分隔)
|
||||
gitlink-cli wiki +lint --check links
|
||||
gitlink-cli wiki +lint --check links,headings
|
||||
|
||||
# JSON 格式输出
|
||||
gitlink-cli wiki +lint --format json
|
||||
```
|
||||
|
||||
### 检查项
|
||||
|
||||
| 检查名称 | 级别 | 说明 |
|
||||
|----------|------|------|
|
||||
| empty | error | 页面内容为空 |
|
||||
| headings | warning | 缺少 H1 标题(不以 `# ` 开头) |
|
||||
| short | warning | 内容不足 50 字符 |
|
||||
| links | error | wiki 内链指向不存在的页面 |
|
||||
| images | warning | 外部图片 URL 返回 404 或不可达 |
|
||||
|
||||
### 主要修改
|
||||
|
||||
**文件**: `shortcuts/wiki/wiki.go`
|
||||
|
||||
#### 1. 新增类型
|
||||
|
||||
```go
|
||||
type LintIssue struct {
|
||||
Page string `json:"page"`
|
||||
Level string `json:"level"` // "error" / "warning"
|
||||
Check string `json:"check"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LintSummary struct {
|
||||
Repository string `json:"repository"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalIssues int `json:"total_issues"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Results []LintIssue `json:"results"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Bug 修复
|
||||
|
||||
- 修复 lint 获取页面内容时使用 `title` 而非 `sub_url` 导致部分页面 500 错误的问题
|
||||
|
||||
#### 3. 新增函数(含 sub_url/title 双字段支持)
|
||||
|
||||
- `isCheckEnabled(checkFilter, name string) bool` — 判断指定检查是否被 `--check` 参数选中
|
||||
- `checkEmpty(page, content string) []LintIssue` — 检测空页面
|
||||
- `checkHeading(page, content string) []LintIssue` — 检测缺少 H1
|
||||
- `checkShort(page, content string) []LintIssue` — 检测内容过短
|
||||
- `checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue` — 正则提取内链,在已知标题集合中查找死链
|
||||
- `checkImages(page, content string, httpClient *http.Client) []LintIssue` — 正则提取外部图片 URL,HEAD 请求检查可达性(超时 5s)
|
||||
- `runLint(ctx *common.RuntimeContext) error` — lint 主流程:解析 owner/repo → 获取页面列表 → 逐页检查 → 汇总输出
|
||||
|
||||
#### 4. 新增 Shortcut 注册
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "lint",
|
||||
Description: "Check wiki pages for quality issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
|
||||
},
|
||||
Run: runLint,
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 新增 import
|
||||
|
||||
`net/http`, `regexp`, `strings`, `time`
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- ✅ 自动执行代码审查
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
---
|
||||
name: gitlink-wiki
|
||||
version: 1.0.0
|
||||
description: "Wiki 页面管理:列出、查看、创建、更新、删除 Wiki 页面。当用户需要管理项目 Wiki 文档时触发。"
|
||||
description: "Wiki 管理:查看、创建、更新、删除 Wiki 页面。当用户需要操作 GitLink Wiki 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli wiki --help"
|
||||
---
|
||||
|
||||
# gitlink-wiki(Wiki 页面操作)
|
||||
# gitlink-wiki(Wiki 操作)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 功能概述
|
||||
|
||||
GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创建、编辑和管理。Wiki 功能通过 GitLink Gateway API (`https://gateway.gitlink.org.cn`) 提供,与核心 GitLink API 使用不同的 Base URL。
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)
|
||||
|
||||
## Shortcuts
|
||||
|
||||
|
|
@ -26,9 +22,9 @@ GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创
|
|||
|----------|------|----------|
|
||||
| `wiki +list` | 列出所有 Wiki 页面 | 否(公开项目) |
|
||||
| `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) |
|
||||
| `wiki +create` | 创建 Wiki 页面 | 是 |
|
||||
| `wiki +update` | 更新 Wiki 页面 | 是 |
|
||||
| `wiki +delete` | 删除 Wiki 页面 | 是 |
|
||||
| `wiki +create` | 创建 Wiki 页面,支持 `--dry-run` 预览 | 是 |
|
||||
| `wiki +update` | 更新 Wiki 页面,支持 `--dry-run` 预览 | 是 |
|
||||
| `wiki +delete` | 删除 Wiki 页面,支持 `--dry-run` 预览 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -36,217 +32,50 @@ GitLink Wiki 提供项目文档协作功能,支持 Markdown 格式的页面创
|
|||
# 列出所有 Wiki 页面
|
||||
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看 Wiki 页面
|
||||
gitlink-cli wiki +view --title "Home"
|
||||
# 查看 Wiki 页面内容(自动解码 base64 并输出 content_decoded 字段)
|
||||
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home"
|
||||
|
||||
# 创建 Wiki 页面(使用 --content 提供内容)
|
||||
gitlink-cli wiki +create --title "API Reference" --content "# API Reference\n\n..."
|
||||
# 创建 Wiki 页面(从命令行内容)
|
||||
gitlink-cli wiki +create --owner myuser --repo myrepo --title "设计文档" --content "# 架构设计\n\n## 概述\n..."
|
||||
|
||||
# 创建 Wiki 页面(从文件读取)
|
||||
gitlink-cli wiki +create --title "Getting Started" --file README.md
|
||||
# 创建 Wiki 页面(从文件)
|
||||
gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api-docs.md
|
||||
|
||||
# 预览创建操作(不实际执行)
|
||||
gitlink-cli wiki +create --owner myuser --repo myrepo --title "测试页面" --content "test" --dry-run
|
||||
|
||||
# 更新 Wiki 页面(覆盖整个内容)
|
||||
gitlink-cli wiki +update --title "Home" --cover "# Updated content"
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "# 新内容\n..."
|
||||
|
||||
# 更新 Wiki 页面(追加内容)
|
||||
gitlink-cli wiki +update --title "Home" --add "\n\n## New Section\n\nAdditional content"
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "\n\n## 新增章节\n..."
|
||||
|
||||
# 更新 Wiki 页面(从文件追加)
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --file ./new-section.md --add
|
||||
|
||||
# 重命名 Wiki 页面(--page 指定当前标题,--title 指定新标题)
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --page "旧标题" --title "新标题"
|
||||
|
||||
# 预览更新操作
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "新内容" --dry-run
|
||||
|
||||
# 删除 Wiki 页面
|
||||
gitlink-cli wiki +delete --title "Old Page"
|
||||
gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面"
|
||||
|
||||
# 预览删除操作
|
||||
gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run
|
||||
```
|
||||
|
||||
## Wiki 内容编码
|
||||
## Wiki 页面内容格式
|
||||
|
||||
所有 Wiki 内容都会自动进行 **Base64 编码**:
|
||||
- Wiki 内容以 **base64** 编码传输,CLI 已自动处理编码/解码
|
||||
- `wiki +view` 返回结果中包含 `content_decoded` 字段(原始文本内容)
|
||||
- `--content` 和 `--file` 参数接受原始文本,CLI 会自动 base64 编码后发送
|
||||
|
||||
- **创建页面**:`--content` 或 `--file` 提供的内容会自动 Base64 编码
|
||||
- **更新页面**:`--cover` 或 `--add` 提供的内容会自动 Base64 编码
|
||||
- **查看页面**:返回的 `content_base64` 字段需要解码,CLI 会自动提供 `content_decoded` 字段
|
||||
## API 注意事项
|
||||
|
||||
**无需手动编码/解码** — CLI 自动处理。
|
||||
|
||||
## 更新模式
|
||||
|
||||
`wiki +update` 支持两种更新模式:
|
||||
|
||||
### 1. 覆盖模式 (`--cover`)
|
||||
完全替换页面内容:
|
||||
|
||||
```bash
|
||||
gitlink-cli wiki +update --title "Home" --cover "# New Content"
|
||||
```
|
||||
|
||||
### 2. 追加模式 (`--add`)
|
||||
在现有内容基础上追加:
|
||||
|
||||
```bash
|
||||
gitlink-cli wiki +update --title "Home" --add "\n\n## Additional Section"
|
||||
```
|
||||
|
||||
**工作原理**:
|
||||
1. CLI 先获取当前页面内容
|
||||
2. 将新内容追加到现有内容后
|
||||
3. 提交更新后的完整内容
|
||||
|
||||
### 3. 重命名 + 更新
|
||||
```bash
|
||||
# 将 "Old-Title" 重命名为 "New-Title" 并更新内容
|
||||
gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated content"
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 通用参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--owner` | 仓库所有者(自动从 git remote 解析) |
|
||||
| `--repo` | 仓库名称(自动从 git remote 解析) |
|
||||
| `--format` | 输出格式: `json`/`table`/`yaml` |
|
||||
| `--debug` | 开启调试输出 |
|
||||
|
||||
### Wiki 专用参数
|
||||
|
||||
| 参数 | Short | 说明 | 适用于 |
|
||||
|------|-------|------|--------|
|
||||
| `--title` | `-t` | Wiki 页面标题 | `+view`, `+create`, `+update`, `+delete` |
|
||||
| `--content` | `-c` | Wiki 页面内容(纯文本) | `+create` |
|
||||
| `--file` | `-f` | 从文件读取内容 | `+create`, `+update` |
|
||||
| `--page` | `-p` | 当前页面标题(用于查找和重命名) | `+update` |
|
||||
| `--cover` | `-c` | 覆盖整个页面内容 | `+update` |
|
||||
| `--add` | `-a` | 追加内容到现有页面 | `+update` |
|
||||
| `--message` | `-m` | 提交消息 | `+create`, `+update` |
|
||||
|
||||
## API 架构
|
||||
|
||||
### Base URL 差异
|
||||
|
||||
**重要**:Wiki 功能使用不同的 API Base URL:
|
||||
|
||||
| 模块 | Base URL |
|
||||
|------|----------|
|
||||
| 核心 API (repo, issue, pr) | `https://www.gitlink.org.cn/api` |
|
||||
| Wiki API | `https://gateway.gitlink.org.cn/api` |
|
||||
|
||||
### Gateway API 响应格式
|
||||
|
||||
Wiki API 返回的响应格式需要特殊处理:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
CLI 自动解析 Gateway 响应并提取 `data` 字段。
|
||||
|
||||
## 项目 ID 解析
|
||||
|
||||
Wiki 操作需要 `project_id`(数据库内部 ID),而不仅仅是 `owner/repo`。
|
||||
|
||||
**CLI 自动处理**:
|
||||
1. 首先调用 `/api/:owner/:repo/detail` 获取 `project_id`
|
||||
2. 使用 `project_id` 调用 Wiki Gateway API
|
||||
3. 缓存 `project_id` 避免重复请求
|
||||
|
||||
**无需手动获取 project_id** — CLI 自动完成。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么 Wiki API 返回 404?
|
||||
|
||||
**A:** 可能原因:
|
||||
1. 页面标题不区分大小写,但必须完全匹配
|
||||
2. 项目没有启用 Wiki 功能
|
||||
3. 权限不足(私有项目需要认证)
|
||||
|
||||
### Q: 更新操作失败怎么办?
|
||||
|
||||
**A:** 检查:
|
||||
1. `--title` 是否指定了正确的目标页面标题
|
||||
2. 如果是重命名,`--page` 是否指定了当前页面标题
|
||||
3. 是否有足够的权限修改 Wiki
|
||||
|
||||
### Q: 如何创建多级 Wiki 页面?
|
||||
|
||||
**A:** GitLink Wiki 不支持真正的目录结构,但可以通过命名约定模拟:
|
||||
```
|
||||
"API/Authentication" # 使用斜杠
|
||||
"API/Authorization" # 模拟层级
|
||||
"Getting-Started" # 使用连字符
|
||||
```
|
||||
|
||||
### Q: 支持 Markdown 哪些语法?
|
||||
|
||||
**A:** GitLink Wiki 支持 CommonMark 标准的 Markdown,包括:
|
||||
- 标题 (`#`, `##`, `###`)
|
||||
- 列表(有序、无序)
|
||||
- 代码块(```)
|
||||
- 链接 (`[text](url)`)
|
||||
- 图片 (``)
|
||||
- 表格
|
||||
- 粗体、斜体
|
||||
|
||||
### 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` 有容错逻辑:如果删除接口返回错误,会二次验证页面是否已不存在
|
||||
|
|
|
|||
Loading…
Reference in New Issue