forked from z2_cc/gitlink-cli
Compare commits
130 Commits
fix/window
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
b46352af51 | |
|
|
21a3447581 | |
|
|
5866600401 | |
|
|
3703594d66 | |
|
|
955e8dd2b7 | |
|
|
f1e237ad18 | |
|
|
b914138338 | |
|
|
75a8ed235a | |
|
|
e9f36c29fb | |
|
|
f248ed9d04 | |
|
|
f9efae89a3 | |
|
|
d12ce0fb57 | |
|
|
c7a2ca3685 | |
|
|
386e3c92b8 | |
|
|
612deddb57 | |
|
|
fb051af813 | |
|
|
c345b5af5d | |
|
|
c192b26da8 | |
|
|
5a5db6d298 | |
|
|
d20b3b09e1 | |
|
|
0ff97fd213 | |
|
|
f5f1a34310 | |
|
|
a8be1a8100 | |
|
|
46eff83ef5 | |
|
|
01e4515cb9 | |
|
|
fd647b7e75 | |
|
|
5c8cc3462c | |
|
|
a31b8e5b11 | |
|
|
7fa93cb3c1 | |
|
|
68e7994d08 | |
|
|
81cfcfb3e4 | |
|
|
596fb81a55 | |
|
|
84aae8554b | |
|
|
be152b9fa1 | |
|
|
5c9e24c725 | |
|
|
59240d18c5 | |
|
|
799af5f3cf | |
|
|
cafde7d2f4 | |
|
|
2aa1b2ffe4 | |
|
|
bc12b43fc0 | |
|
|
30f16b9613 | |
|
|
461b28e6c8 | |
|
|
d66c88ecca | |
|
|
295eda5a4e | |
|
|
25ee6cc5fb | |
|
|
f1dc2f8bc9 | |
|
|
b2136559bf | |
|
|
16787b8f8c | |
|
|
2596d1dc70 | |
|
|
65a4e60d6d | |
|
|
db8f3c7cae | |
|
|
76cb5be259 | |
|
|
cbbec86b49 | |
|
|
503a5aeead | |
|
|
1d4e177152 | |
|
|
d1cc68b6d9 | |
|
|
6c0ad3b7ae | |
|
|
9a51db0989 | |
|
|
a4587f95c4 | |
|
|
38658fb7b1 | |
|
|
5eb67055fd | |
|
|
dd905fb045 | |
|
|
e60e876fad | |
|
|
a115c97dde | |
|
|
66ec432036 | |
|
|
cb238f132c | |
|
|
240a4ff6b7 | |
|
|
d46a0cc48b | |
|
|
59552d9286 | |
|
|
811bae1cd5 | |
|
|
015a30caa5 | |
|
|
e3470ba592 | |
|
|
167a2ff12c | |
|
|
63cbb2cef8 | |
|
|
9f479a27db | |
|
|
f18eea273f | |
|
|
6fda83bf38 | |
|
|
92a953ba56 | |
|
|
9a2e6a3280 | |
|
|
3a47936f9f | |
|
|
15110df6f1 | |
|
|
b9638fe0b6 | |
|
|
1762a24851 | |
|
|
5ae0a134bb | |
|
|
2669c632d0 | |
|
|
3ee71a1ffc | |
|
|
87277cbf4f | |
|
|
fef44dc5e3 | |
|
|
d1e44867c3 | |
|
|
8d3d6cc5ce | |
|
|
9598142c2f | |
|
|
52dc8a9967 | |
|
|
2ddad05646 | |
|
|
44b184723e | |
|
|
c7d374ab32 | |
|
|
aebb24d51c | |
|
|
9f99247422 | |
|
|
637be3cb4b | |
|
|
4121524100 | |
|
|
c2e0b0da18 | |
|
|
a94088e4ee | |
|
|
94661010ac | |
|
|
ca7f5aacce | |
|
|
667f28a618 | |
|
|
8df76dcb7a | |
|
|
b64a5104a8 | |
|
|
a46e06b78a | |
|
|
64c0ff3cb5 | |
|
|
cbe9186c5c | |
|
|
2fb99ee0cc | |
|
|
4bfbfc6e8f | |
|
|
49cd590227 | |
|
|
2b64b8efa7 | |
|
|
9b6268cc47 | |
|
|
255410181f | |
|
|
d0a817c5a8 | |
|
|
ba78cb75b2 | |
|
|
fbea42ecce | |
|
|
67059ec1b2 | |
|
|
c57bd280c5 | |
|
|
e85dd367d8 | |
|
|
a302f2a530 | |
|
|
923e6af127 | |
|
|
7bd29fdf15 | |
|
|
478e7c8f88 | |
|
|
df1d6bb29e | |
|
|
fde322669a | |
|
|
cdca68ff3e | |
|
|
2f84f3c62e | |
|
|
83a642a72d |
|
|
@ -0,0 +1,37 @@
|
|||
version: 2
|
||||
name: 构建流水线
|
||||
description: "gitlink-cli CI 流水线:拉取代码、构建、验证"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 拉取代码并构建验证
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"export GOPROXY=https://goproxy.cn,direct && cd /root/gitlink-cli || git clone https://gitlink.org.cn/z2_cc/gitlink-cli.git /root/gitlink-cli; cd /root/gitlink-cli && git pull origin master && go build -o gitlink-cli . && ./gitlink-cli version"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
- name: Build
|
||||
run: go build -ldflags "-s -w" .
|
||||
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
@ -27,171 +27,108 @@ jobs:
|
|||
run: |
|
||||
mkdir -p dist
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
||||
for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64"; do
|
||||
GOOS=$(echo $pair | cut -d' ' -f1)
|
||||
GOARCH=$(echo $pair | cut -d' ' -f2)
|
||||
MODULE="github.com/gitlink-org/gitlink-cli"
|
||||
LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}"
|
||||
|
||||
for pair in \
|
||||
"darwin amd64" \
|
||||
"darwin arm64" \
|
||||
"linux amd64" \
|
||||
"linux arm64" \
|
||||
"windows amd64" \
|
||||
"windows arm64"; do
|
||||
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
||||
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
||||
OUT="gitlink-cli"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUT="gitlink-cli.exe"
|
||||
fi
|
||||
|
||||
echo "Building ${GOOS}-${GOARCH}..."
|
||||
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o dist/gitlink-cli .
|
||||
cd dist
|
||||
tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" gitlink-cli
|
||||
rm gitlink-cli
|
||||
cd ..
|
||||
BUILD_DIR="dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "$BUILD_DIR/$OUT" .
|
||||
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
(cd "$BUILD_DIR" && zip -q "../gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "$OUT")
|
||||
else
|
||||
tar -czf "dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" -C "$BUILD_DIR" "$OUT"
|
||||
fi
|
||||
rm -rf "$BUILD_DIR"
|
||||
done
|
||||
|
||||
# Generate SHA256 checksums
|
||||
cd dist
|
||||
sha256sum *.tar.gz *.zip > checksums-sha256.txt
|
||||
cd ..
|
||||
ls -lh dist
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*.tar.gz
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/checksums-sha256.txt
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Update Scoop manifest
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd dist
|
||||
|
||||
# Parse SHA256 hashes from checksums file
|
||||
declare -A HASHES
|
||||
while read -r hash file; do
|
||||
HASHES["$file"]="$hash"
|
||||
done < checksums-sha256.txt
|
||||
|
||||
# Update bucket/gitlink-cli.json
|
||||
cd ..
|
||||
SCOOP_FILE="bucket/gitlink-cli.json"
|
||||
if [ -f "$SCOOP_FILE" ]; then
|
||||
# Update version
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$SCOOP_FILE"
|
||||
|
||||
# Update amd64 hash and URL
|
||||
AMD64_FILE="gitlink-cli_${VERSION}_windows_amd64.zip"
|
||||
if [ -n "${HASHES[$AMD64_FILE]}" ]; then
|
||||
sed -i "/64bit/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${AMD64_FILE}\"|" "$SCOOP_FILE"
|
||||
sed -i "/64bit/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$AMD64_FILE]}\"|" "$SCOOP_FILE"
|
||||
fi
|
||||
|
||||
# Update arm64 hash and URL
|
||||
ARM64_FILE="gitlink-cli_${VERSION}_windows_arm64.zip"
|
||||
if [ -n "${HASHES[$ARM64_FILE]}" ]; then
|
||||
sed -i "/arm64/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${ARM64_FILE}\"|" "$SCOOP_FILE"
|
||||
sed -i "/arm64/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$ARM64_FILE]}\"|" "$SCOOP_FILE"
|
||||
fi
|
||||
|
||||
echo "Updated $SCOOP_FILE for version $VERSION"
|
||||
fi
|
||||
|
||||
- name: Build npm package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills
|
||||
|
||||
# Copy skills
|
||||
cp -r skills/* npm-pkg/skills/
|
||||
|
||||
# Copy bin wrappers
|
||||
cp bin/cli.js npm-pkg/bin/ 2>/dev/null || cat > npm-pkg/bin/cli.js << 'NODEEOF'
|
||||
#!/usr/bin/env node
|
||||
const {execFileSync} = require("child_process");
|
||||
const path = require("path");
|
||||
const bin = path.join(__dirname, "..", "bin", "gitlink-cli");
|
||||
try { process.exit(execFileSync(bin, process.argv.slice(2), {stdio:"inherit"}).status); }
|
||||
catch(e) { process.exit(e.status || 1); }
|
||||
NODEEOF
|
||||
|
||||
cat > npm-pkg/bin/install-skills.js << 'NODEEOF'
|
||||
#!/usr/bin/env node
|
||||
const {execSync} = require("child_process");
|
||||
const path = require("path");
|
||||
const skillsDir = path.join(__dirname, "..", "skills");
|
||||
try {
|
||||
execSync(`npx skills add "${skillsDir}" -y -g`, {stdio:"inherit"});
|
||||
} catch(e) {
|
||||
console.error("Failed to install skills:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
NODEEOF
|
||||
|
||||
# Copy install.js
|
||||
cp scripts/install.js npm-pkg/scripts/ 2>/dev/null || cat > npm-pkg/scripts/install.js << 'NODEEOF'
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const {execSync} = require("child_process");
|
||||
|
||||
const PACKAGE = require("../package.json");
|
||||
const VERSION = PACKAGE.version;
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
const RELEASE_BASE = "https://github.com";
|
||||
const REPO_OWNER = "ccfos";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const pmap = {darwin:"darwin",linux:"linux",win32:"windows"};
|
||||
const amap = {x64:"amd64",arm64:"arm64"};
|
||||
const p=pmap[platform], a=amap[arch];
|
||||
if(!p||!a) throw new Error(`Unsupported: ${platform}-${arch}`);
|
||||
return {platform:p,arch:a};
|
||||
}
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve,reject) => {
|
||||
const mod=url.startsWith("https")?https:http;
|
||||
let count=0;
|
||||
function req(u) {
|
||||
if(++count>5) return reject(new Error("Too many redirects"));
|
||||
mod.get(u,(res) => {
|
||||
if([301,302,307,308].includes(res.statusCode)&&res.headers.location){
|
||||
let loc=res.headers.location;
|
||||
if(loc.startsWith("/")){const p=new URL(u);loc=p.protocol+"//"+p.host+loc}
|
||||
return req(loc);
|
||||
}
|
||||
if(res.statusCode!==200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
const c=[];res.on("data",d=>c.push(d));res.on("end",()=>resolve(Buffer.concat(c)));
|
||||
}).on("error",reject);
|
||||
}
|
||||
req(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const {platform,arch} = getPlatformInfo();
|
||||
const binDir = path.join(__dirname,"..","bin");
|
||||
if(!fs.existsSync(binDir)) fs.mkdirSync(binDir,{recursive:true});
|
||||
const binaryPath = path.join(binDir,BINARY_NAME);
|
||||
|
||||
if(fs.existsSync(binaryPath)) {
|
||||
try {
|
||||
const out = execSync(`"${binaryPath}" version`,{encoding:"utf-8",timeout:5000});
|
||||
if(out.includes(VERSION)) { console.log(`${BINARY_NAME} v${VERSION} already installed.`); return; }
|
||||
} catch(e) {}
|
||||
fs.unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
const assetName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
|
||||
const url = `${RELEASE_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${assetName}`;
|
||||
console.log(`Downloading ${url}...`);
|
||||
const data = await fetch(url);
|
||||
const tmp = path.join(binDir,"dl.tar.gz");
|
||||
fs.writeFileSync(tmp,data);
|
||||
execSync(`tar -xzf "${tmp}" -C "${binDir}"`,{stdio:"pipe"});
|
||||
fs.unlinkSync(tmp);
|
||||
fs.chmodSync(binaryPath,0o755);
|
||||
console.log(`${BINARY_NAME} v${VERSION} installed.`);
|
||||
} catch(err) {
|
||||
console.warn(`⚠ Binary download failed: ${err.message}`);
|
||||
console.warn(`Skills are installed. Install binary manually:`);
|
||||
console.warn(`npm run postinstall`);
|
||||
}
|
||||
}
|
||||
main();
|
||||
NODEEOF
|
||||
|
||||
# Create README
|
||||
cp README.md npm-pkg/
|
||||
|
||||
# Create package.json
|
||||
cat > npm-pkg/package.json << PKGEOF
|
||||
{
|
||||
"name": "@gitlink-ai/cli",
|
||||
"version": "${VERSION}",
|
||||
"description": "GitLink CLI — 面向 AI Agent 的 GitLink 命令行工具",
|
||||
"main": "bin/cli.js",
|
||||
"bin": {
|
||||
"gitlink-cli": "./bin/cli.js",
|
||||
"gitlink-cli-install-skills": "./bin/install-skills.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"skills/",
|
||||
"README.md",
|
||||
"package.json"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ccfos/gitlink-cli.git"
|
||||
},
|
||||
"keywords": ["gitlink", "cli", "ai-agent", "skills"],
|
||||
"author": "",
|
||||
"license": "MulanPSL-2.0"
|
||||
}
|
||||
PKGEOF
|
||||
|
||||
export VERSION
|
||||
rm -rf npm-pkg
|
||||
mkdir -p npm-pkg
|
||||
cp -R npm/. npm-pkg/
|
||||
cp README.md npm-pkg/README.md
|
||||
rm -rf npm-pkg/skills
|
||||
cp -R skills npm-pkg/skills
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const pkgPath = 'npm-pkg/package.json';
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = process.env.VERSION;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
NODE
|
||||
|
||||
chmod +x npm-pkg/bin/cli.js
|
||||
chmod +x npm-pkg/bin/install-skills.js
|
||||
|
||||
cd npm-pkg
|
||||
npm publish --access public
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# 编译输出
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# 个人工作区
|
||||
my_docs/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
docs/
|
||||
41
Makefile
41
Makefile
|
|
@ -3,7 +3,9 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
|
||||
.PHONY: build install clean test lint cross-build dist checksums
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -13,6 +15,43 @@ install:
|
|||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf dist/
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
# Cross-compile for all supported platforms
|
||||
cross-build:
|
||||
@mkdir -p dist
|
||||
@for pair in $(PLATFORMS); do \
|
||||
GOOS=$${pair%/*}; \
|
||||
GOARCH=$${pair#*/}; \
|
||||
OUT=$(BINARY); \
|
||||
if [ "$$GOOS" = "windows" ]; then OUT=$(BINARY).exe; fi; \
|
||||
echo "Building $$GOOS/$$GOARCH..."; \
|
||||
BUILD_DIR="dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}"; \
|
||||
mkdir -p "$$BUILD_DIR"; \
|
||||
CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH \
|
||||
go build -ldflags "$(LDFLAGS)" -o "$$BUILD_DIR/$$OUT" .; \
|
||||
if [ "$$GOOS" = "windows" ]; then \
|
||||
(cd "$$BUILD_DIR" && zip -q "../$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.zip" "$$OUT"); \
|
||||
else \
|
||||
tar -czf "dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.tar.gz" -C "$$BUILD_DIR" "$$OUT"; \
|
||||
fi; \
|
||||
rm -rf "$$BUILD_DIR"; \
|
||||
done
|
||||
@echo "Build complete:"
|
||||
@ls -lh dist/*.tar.gz dist/*.zip 2>/dev/null
|
||||
|
||||
# Generate SHA256 checksums for all archives in dist/
|
||||
checksums: cross-build
|
||||
@cd dist && \
|
||||
sha256sum *.tar.gz *.zip 2>/dev/null > checksums-sha256.txt && \
|
||||
echo "Checksums written to dist/checksums-sha256.txt" && \
|
||||
cat checksums-sha256.txt
|
||||
|
||||
# Full distribution build: cross-compile + checksums
|
||||
dist: checksums
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -5,16 +5,30 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/).
|
||||
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
|
||||
|
||||
## Contributors
|
||||
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
</div>
|
||||
|
||||
## Why gitlink-cli?
|
||||
|
||||
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
|
||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
|
||||
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
||||
|
|
@ -33,6 +47,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
| 🏷️ Release | Create, view, delete releases |
|
||||
| 🏢 Org | Manage organizations, members, teams |
|
||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 👤 User | View user profiles and info |
|
||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
||||
|
|
@ -55,7 +70,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
**From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# One command: installs CLI binary + all 12 AI Agent Skills
|
||||
# One command: installs CLI binary + all 13 AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
|
|
@ -143,6 +158,23 @@ gitlink-cli repo +create -n my-project -d "Project description"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook Management
|
||||
|
||||
```bash
|
||||
# List webhooks
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# Test a webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# View webhook delivery tasks
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
|
|
@ -188,6 +220,19 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
|||
|
||||
# View changed files
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# List PR patchset versions
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View a patchset version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# List PR reviews
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Create a PR review (with dry-run preview)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### Branch Management
|
||||
|
|
@ -407,6 +452,16 @@ gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment v
|
|||
|
||||
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
|
||||
|
||||
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
|
||||
|
||||
Reinstall first:
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
|
||||
### Q: Where are credentials stored on Windows?
|
||||
|
||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
||||
|
|
|
|||
|
|
@ -5,16 +5,30 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
**[English](./README.md)**
|
||||
|
||||
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
|
||||
|
||||
## 贡献者
|
||||
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
</div>
|
||||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
||||
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
|
|
@ -155,6 +169,23 @@ gitlink-cli repo +create -n my-project -d "项目描述"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook 管理
|
||||
|
||||
```bash
|
||||
# 列出 webhook
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# 测试 webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# 查看 webhook 投递任务
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
```bash
|
||||
|
|
@ -200,6 +231,19 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
|||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR patchset/version 列表
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看指定 patchset/version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# 查看 PR 审查记录
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 创建 PR 审查(支持 dry-run 预览)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
|
|
@ -273,7 +317,7 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
|
||||
|
|
@ -386,6 +430,16 @@ gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment
|
|||
|
||||
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
|
||||
|
||||
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?
|
||||
|
||||
先尝试重新安装:
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
|
||||
|
||||
### Q: Windows 上凭证存储在哪里?
|
||||
|
||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"version": "0.1.13",
|
||||
"description": "CLI tool for GitLink platform",
|
||||
"homepage": "https://www.gitlink.org.cn/Gitlink/gitlink-cli",
|
||||
"license": "MulanPSL-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_amd64.zip",
|
||||
"hash": "PLACEHOLDER_SHA256"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_arm64.zip",
|
||||
"hash": "PLACEHOLDER_SHA256"
|
||||
}
|
||||
},
|
||||
"bin": "gitlink-cli.exe",
|
||||
"checkver": {
|
||||
"github": "https://github.com/gitlink-org/gitlink-cli"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_arm64.zip"
|
||||
}
|
||||
},
|
||||
"hash": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/checksums-sha256.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>gitlink-cli</id>
|
||||
<version>0.1.13</version>
|
||||
<title>GitLink CLI</title>
|
||||
<authors>GitLink</authors>
|
||||
<projectUrl>https://www.gitlink.org.cn/Gitlink/gitlink-cli</projectUrl>
|
||||
<licenseUrl>https://opensource.org/licenses/MulanPSL-2.0</licenseUrl>
|
||||
<description>CLI tool for GitLink platform</description>
|
||||
<tags>gitlink cli devops</tags>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="tools/**" target="tools" />
|
||||
</files>
|
||||
</package>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$packageName = 'gitlink-cli'
|
||||
$version = '0.1.13'
|
||||
$url = "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
|
||||
$checksum = 'PLACEHOLDER_SHA256'
|
||||
$installDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||
$packageArgs = @{
|
||||
packageName = $packageName
|
||||
url = $url
|
||||
checksum = $checksum
|
||||
checksumType = 'sha256'
|
||||
unzipLocation = $installDir
|
||||
}
|
||||
Install-ChocolateyZipPackage @packageArgs
|
||||
|
|
@ -23,11 +23,13 @@ func NewAuthCmd() *cobra.Command {
|
|||
cmd.AddCommand(newLoginCmd())
|
||||
cmd.AddCommand(newLogoutCmd())
|
||||
cmd.AddCommand(newStatusCmd())
|
||||
cmd.AddCommand(newRefreshCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
var tokenMode bool
|
||||
var oauthMode bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
|
|
@ -36,10 +38,14 @@ func newLoginCmd() *cobra.Command {
|
|||
if tokenMode {
|
||||
return loginWithToken()
|
||||
}
|
||||
if oauthMode {
|
||||
return loginWithOAuth()
|
||||
}
|
||||
return loginWithPassword()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
||||
cmd.Flags().BoolVar(&oauthMode, "oauth", false, "Login using OAuth2 password grant")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -140,3 +146,42 @@ func newStatusCmd() *cobra.Command {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loginWithOAuth() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.LoginOAuth(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OAuth2 login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Logged in as %s\n", result.Login)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRefreshCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Refresh OAuth2 token",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
result, err := internalAuth.RefreshOAuthToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Token refreshed for %s (expires in %d seconds)\n", result.Login, result.ExpiresIn)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package cmdutil
|
|||
|
||||
// Global flags shared across all commands.
|
||||
var (
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
ColorMode string
|
||||
AutoConfirm bool
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
// TestAllCommandsExecuteWithoutPanic 验证所有注册的命令都能安全执行:
|
||||
// 不 panic、恢复 stdout、且产生输出或合理的错误。
|
||||
// 这是回归测试,防止之前 "选择命令后无输出" 的 nil-Client panic 复发。
|
||||
func TestAllCommandsExecuteWithoutPanic(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
if len(all) == 0 {
|
||||
t.Fatal("GetAllShortcuts returned empty registry")
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
totalCommands := 0
|
||||
silentFailures := 0
|
||||
|
||||
for group, shortcuts := range all {
|
||||
for _, s := range shortcuts {
|
||||
if s.Run == nil {
|
||||
continue
|
||||
}
|
||||
totalCommands++
|
||||
// 用空参数执行;需要 owner/repo/必填参数的命令会返回错误,这是正常的
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
|
||||
// 断言1:不能既无输出又无错误(静默失败)
|
||||
if strings.TrimSpace(out) == "" && err == nil {
|
||||
silentFailures++
|
||||
t.Errorf("[%s +%s] 静默失败:无输出且无错误", group, s.Name)
|
||||
}
|
||||
|
||||
// 断言2:错误信息不应包含 "panic"(说明 recover 被触发,命令有 bug)
|
||||
if err != nil && strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("[%s +%s] 命令 panic: %v", group, s.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("测试了 %d 个命令,%d 个静默失败", totalCommands, silentFailures)
|
||||
if totalCommands < 50 {
|
||||
t.Errorf("命令总数 %d 偏少,预期至少 50 个", totalCommands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllCommandsHaveMetadata 验证所有命令都有名称、描述和 Run 函数。
|
||||
func TestAllCommandsHaveMetadata(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
for group, shortcuts := range all {
|
||||
if len(shortcuts) == 0 {
|
||||
t.Errorf("命令组 %q 没有任何子命令", group)
|
||||
}
|
||||
for _, s := range shortcuts {
|
||||
if s.Name == "" {
|
||||
t.Errorf("[%s] 有命令名称为空", group)
|
||||
}
|
||||
if s.Description == "" {
|
||||
t.Errorf("[%s +%s] 描述为空", group, s.Name)
|
||||
}
|
||||
if s.Run == nil {
|
||||
t.Errorf("[%s +%s] Run 函数为空", group, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestE2E_ExecutingRealCommandProducesOutput is an end-to-end regression for
|
||||
// "selecting a command shows no output". It drives the real shortcut registry
|
||||
// (repo +list) through the executor and asserts that Execute returns either
|
||||
// captured stdout OR a non-nil error — never both empty, which is what left
|
||||
// the REPL silent before the RuntimeContext fix.
|
||||
func TestE2E_ExecutingRealCommandProducesOutput(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
s, ok := findShortcut(all, "repo", "list")
|
||||
if !ok {
|
||||
t.Fatal("repo +list not found in registry")
|
||||
}
|
||||
if s.Run == nil {
|
||||
t.Fatal("repo +list has no Run function")
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
// repo +list needs an owner; without --owner it tries git remote detection.
|
||||
// Either way the executor must return something (output or error), not hang.
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
|
||||
// The contract: Execute never returns (empty, nil) for a real command —
|
||||
// either it produced output, or it returned an error explaining the failure.
|
||||
if out == "" && err == nil {
|
||||
t.Fatal("Execute returned empty output AND nil error — REPL would show nothing (regression)")
|
||||
}
|
||||
|
||||
// A non-empty result (output or error message) proves the command actually
|
||||
// ran through a properly-initialized RuntimeContext instead of panicking.
|
||||
t.Logf("output=%q err=%v", out, err)
|
||||
_ = common.RuntimeContext{} // keep import
|
||||
_ = strings.TrimSpace
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Executor runs shortcut commands and captures their stdout output.
|
||||
type Executor struct{}
|
||||
|
||||
// Execute runs the given shortcut with the provided arguments, capturing
|
||||
// anything written to os.Stdout during execution and returning it as a string.
|
||||
//
|
||||
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
|
||||
// Format, Owner and Repo are properly initialized — a bare
|
||||
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
|
||||
// command calls ctx.CallAPI. A deferred recover guards against panics raised
|
||||
// inside the command so the REPL is never left with stdout pointing at a
|
||||
// closed pipe.
|
||||
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
|
||||
// Guard against panics inside the command: always restore stdout even if
|
||||
// s.Run panics, and surface the panic as a regular error so the REPL can
|
||||
// display it instead of hanging with a corrupted stdout.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
output = ""
|
||||
err = fmt.Errorf("command panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Save original stdout
|
||||
oldStdout := os.Stdout
|
||||
|
||||
// Create a pipe: writes go to w, reads come from r
|
||||
r, w, perr := os.Pipe()
|
||||
if perr != nil {
|
||||
return "", fmt.Errorf("failed to create pipe: %w", perr)
|
||||
}
|
||||
|
||||
// Redirect stdout to the write end of the pipe
|
||||
os.Stdout = w
|
||||
|
||||
// Channel to signal that the goroutine has finished reading
|
||||
done := make(chan struct{})
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Read from the pipe in a goroutine so that writes don't block
|
||||
go func() {
|
||||
io.Copy(&buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Build a fully-initialized context (Client/Format/Owner/Repo).
|
||||
ctx, cerr := common.NewRuntimeContext(args)
|
||||
if cerr != nil {
|
||||
// Restore stdout and drain the pipe before returning.
|
||||
os.Stdout = oldStdout
|
||||
w.Close()
|
||||
<-done
|
||||
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
|
||||
}
|
||||
|
||||
// Execute the shortcut's Run function. Recover protects the path so that
|
||||
// a panic still leaves the deferred cleanup below runnable.
|
||||
runErr := safeRun(s, ctx)
|
||||
|
||||
// Close the writer to signal EOF to the reader goroutine, wait for it to
|
||||
// finish copying into the buffer, then restore stdout.
|
||||
w.Close()
|
||||
<-done
|
||||
os.Stdout = oldStdout
|
||||
|
||||
return buf.String(), runErr
|
||||
}
|
||||
|
||||
// safeRun invokes a shortcut's Run function and converts any panic into an
|
||||
// error, so a panicking command cannot crash the executor goroutine or leave
|
||||
// os.Stdout redirected at a closed pipe.
|
||||
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("command panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
// parseDirectCommand parses a direct command string like "issue +list --state open"
|
||||
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
|
||||
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) < 2 {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
group = parts[0]
|
||||
rawCmd := parts[1]
|
||||
|
||||
if !strings.HasPrefix(rawCmd, "+") {
|
||||
return "", "", "", false
|
||||
}
|
||||
cmd = strings.TrimPrefix(rawCmd, "+")
|
||||
|
||||
if len(parts) > 2 {
|
||||
flagStr = strings.Join(parts[2:], " ")
|
||||
}
|
||||
|
||||
return group, cmd, flagStr, true
|
||||
}
|
||||
|
||||
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
|
||||
// shortMap is used to expand short flag names to their long equivalents.
|
||||
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
if flagStr == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
parts := strings.Fields(flagStr)
|
||||
|
||||
for i := 0; i < len(parts); i++ {
|
||||
part := parts[i]
|
||||
|
||||
if strings.HasPrefix(part, "--") {
|
||||
// Long flag
|
||||
flagPart := strings.TrimPrefix(part, "--")
|
||||
|
||||
if strings.Contains(flagPart, "=") {
|
||||
// --flag=value format
|
||||
kv := strings.SplitN(flagPart, "=", 2)
|
||||
result[kv[0]] = kv[1]
|
||||
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||||
// --flag value format
|
||||
result[flagPart] = parts[i+1]
|
||||
i++
|
||||
} else {
|
||||
// --flag without value (boolean-like)
|
||||
result[flagPart] = "true"
|
||||
}
|
||||
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
|
||||
// Short flag
|
||||
shortName := strings.TrimPrefix(part, "-")
|
||||
|
||||
// Expand short name to long name if mapping exists
|
||||
longName := shortName
|
||||
if mapped, ok := shortMap[shortName]; ok {
|
||||
longName = mapped
|
||||
}
|
||||
|
||||
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||||
result[longName] = parts[i+1]
|
||||
i++
|
||||
} else {
|
||||
result[longName] = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// findShortcut looks up a shortcut by group and command name in the registry.
|
||||
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
|
||||
shortcuts, ok := all[group]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, s := range shortcuts {
|
||||
if s.Name == cmd {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// buildShortMap creates a mapping from short flag names to long flag names.
|
||||
func buildShortMap(s *common.Shortcut) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, f := range s.Flags {
|
||||
if f.Short != "" {
|
||||
m[f.Short] = f.Name
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// missingRequiredFlags returns the names (with -- prefix) of flags that are
|
||||
// marked as required but not present in args.
|
||||
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
|
||||
var missing []string
|
||||
for _, f := range s.Flags {
|
||||
if f.Required {
|
||||
if _, ok := args[f.Name]; !ok {
|
||||
missing = append(missing, "--"+f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
|
||||
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(group)
|
||||
b.WriteString(" +")
|
||||
b.WriteString(s.Name)
|
||||
|
||||
// Sort flag names for deterministic output
|
||||
keys := make([]string, 0, len(args))
|
||||
for k := range args {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
b.WriteString(" --")
|
||||
b.WriteString(k)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(args[k])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestExecutor_UsesRealRuntimeContext verifies that Execute builds the
|
||||
// RuntimeContext via NewRuntimeContext (which initializes Client/Format/etc.)
|
||||
// rather than a bare struct literal that leaves Client == nil.
|
||||
//
|
||||
// Regression: previously Execute did `&common.RuntimeContext{Args: args}`,
|
||||
// so any command calling ctx.CallAPI panicked with a nil pointer dereference
|
||||
// inside the executor goroutine, which swallowed the execResultMsg and left
|
||||
// the REPL showing no output at all.
|
||||
func TestExecutor_UsesRealRuntimeContext(t *testing.T) {
|
||||
called := false
|
||||
s := &common.Shortcut{
|
||||
Name: "probe",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
called = true
|
||||
// A real command uses ctx.Client / ctx.Format. If Execute built the
|
||||
// context correctly, Client must be non-nil and Format non-empty.
|
||||
if ctx.Client == nil {
|
||||
t.Error("ctx.Client is nil — Execute did not use NewRuntimeContext")
|
||||
}
|
||||
if ctx.Format == "" {
|
||||
t.Error("ctx.Format is empty — Execute did not initialize format")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("Run was not invoked")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_PanicDoesNotCorruptStdout verifies that if a command panics,
|
||||
// Execute recovers, returns the panic as an error, and — critically — restores
|
||||
// os.Stdout so the REPL is not left with stdout pointing at a closed pipe.
|
||||
func TestExecutor_PanicDoesNotCorruptStdout(t *testing.T) {
|
||||
original := os.Stdout
|
||||
s := &common.Shortcut{
|
||||
Name: "boom",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
panic("simulated command failure")
|
||||
},
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
_, err := exec.Execute(s, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected Execute to return an error for a panicking command")
|
||||
}
|
||||
if os.Stdout != original {
|
||||
t.Fatal("os.Stdout was not restored after a panic — REPL would be left broken")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestExecutor_CaptureOutput verifies that Execute captures stdout output
|
||||
// even when the Run function writes directly to os.Stdout.
|
||||
func TestExecutor_CaptureOutput(t *testing.T) {
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// Do nothing — output should be empty
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
output, err := e.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if output != "" {
|
||||
t.Fatalf("expected empty output, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_CaptureOutputPrints verifies that stdout written inside Run
|
||||
// is captured and returned as a string.
|
||||
func TestExecutor_CaptureOutputPrints(t *testing.T) {
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
fmt.Fprint(os.Stdout, "hello from command")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
output, err := e.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if output != "hello from command" {
|
||||
t.Fatalf("expected %q, got %q", "hello from command", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_RestoresStdout verifies that os.Stdout is restored after Execute.
|
||||
func TestExecutor_RestoresStdout(t *testing.T) {
|
||||
original := os.Stdout
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _ = e.Execute(s, map[string]string{})
|
||||
|
||||
if os.Stdout != original {
|
||||
t.Fatal("os.Stdout was not restored after Execute")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDirectCommand uses table-driven tests for parseDirectCommand.
|
||||
func TestParseDirectCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
group string
|
||||
cmd string
|
||||
flagStr string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "single word only",
|
||||
input: "issue",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "second word without plus",
|
||||
input: "issue list",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "two words with plus",
|
||||
input: "issue +list",
|
||||
group: "issue",
|
||||
cmd: "list",
|
||||
flagStr: "",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "with flags",
|
||||
input: "issue +list --state open",
|
||||
group: "issue",
|
||||
cmd: "list",
|
||||
flagStr: "--state open",
|
||||
ok: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
group, cmd, flagStr, ok := parseDirectCommand(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if ok {
|
||||
if group != tt.group {
|
||||
t.Errorf("group = %q, want %q", group, tt.group)
|
||||
}
|
||||
if cmd != tt.cmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.cmd)
|
||||
}
|
||||
if flagStr != tt.flagStr {
|
||||
t.Errorf("flagStr = %q, want %q", flagStr, tt.flagStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFlags uses table-driven tests for parseFlagString.
|
||||
func TestParseFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flagStr string
|
||||
shortMap map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "long flags only",
|
||||
flagStr: "--title hello --body world",
|
||||
shortMap: map[string]string{},
|
||||
want: map[string]string{
|
||||
"title": "hello",
|
||||
"body": "world",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "short flag expanded",
|
||||
flagStr: "-n 42",
|
||||
shortMap: map[string]string{"n": "number"},
|
||||
want: map[string]string{
|
||||
"number": "42",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "equals syntax",
|
||||
flagStr: "--title=hello",
|
||||
shortMap: map[string]string{},
|
||||
want: map[string]string{
|
||||
"title": "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFlagString(tt.flagStr, tt.shortMap)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %d flags, want %d flags: %v", len(got), len(tt.want), got)
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("flag %q = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindShortcut covers three scenarios: found, wrong group, wrong cmd.
|
||||
func TestFindShortcut(t *testing.T) {
|
||||
listShortcut := &common.Shortcut{Name: "list", Description: "list issues"}
|
||||
createShortcut := &common.Shortcut{Name: "create", Description: "create issue"}
|
||||
all := map[string][]*common.Shortcut{
|
||||
"issue": {listShortcut, createShortcut},
|
||||
"pr": {{Name: "list", Description: "list PRs"}},
|
||||
}
|
||||
|
||||
// Scenario 1: found
|
||||
s, ok := findShortcut(all, "issue", "list")
|
||||
if !ok {
|
||||
t.Fatal("expected to find issue +list")
|
||||
}
|
||||
if s != listShortcut {
|
||||
t.Fatal("returned wrong shortcut")
|
||||
}
|
||||
|
||||
// Scenario 2: group not found
|
||||
_, ok = findShortcut(all, "repo", "list")
|
||||
if ok {
|
||||
t.Fatal("expected not found for unknown group")
|
||||
}
|
||||
|
||||
// Scenario 3: group found but cmd not found
|
||||
_, ok = findShortcut(all, "issue", "close")
|
||||
if ok {
|
||||
t.Fatal("expected not found for unknown cmd in valid group")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildShortMap verifies short-to-long flag mapping construction.
|
||||
func TestBuildShortMap(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "list",
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s"},
|
||||
{Name: "number", Short: "n"},
|
||||
{Name: "verbose"},
|
||||
},
|
||||
}
|
||||
|
||||
m := buildShortMap(s)
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("expected 2 entries in short map, got %d", len(m))
|
||||
}
|
||||
if m["s"] != "state" {
|
||||
t.Errorf("short 's' = %q, want 'state'", m["s"])
|
||||
}
|
||||
if m["n"] != "number" {
|
||||
t.Errorf("short 'n' = %q, want 'number'", m["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingRequiredFlags checks that required-but-missing flags are reported.
|
||||
func TestMissingRequiredFlags(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "create",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Required: true},
|
||||
{Name: "body", Required: true},
|
||||
{Name: "state", Required: false},
|
||||
},
|
||||
}
|
||||
|
||||
// Provide title but not body
|
||||
args := map[string]string{"title": "hello"}
|
||||
missing := missingRequiredFlags(s, args)
|
||||
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("expected 1 missing flag, got %d: %v", len(missing), missing)
|
||||
}
|
||||
if missing[0] != "--body" {
|
||||
t.Errorf("missing flag = %q, want '--body'", missing[0])
|
||||
}
|
||||
|
||||
// Provide all required
|
||||
args2 := map[string]string{"title": "hello", "body": "world"}
|
||||
missing2 := missingRequiredFlags(s, args2)
|
||||
if len(missing2) != 0 {
|
||||
t.Fatalf("expected 0 missing flags, got %d: %v", len(missing2), missing2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatCommandDisplay verifies the display string format.
|
||||
func TestFormatCommandDisplay(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "create",
|
||||
Description: "create an issue",
|
||||
}
|
||||
args := map[string]string{
|
||||
"title": "hello",
|
||||
"body": "world",
|
||||
}
|
||||
|
||||
got := formatCommandDisplay("issue", s, args)
|
||||
|
||||
// The order of flags may vary, so check that all parts are present
|
||||
if !strings.HasPrefix(got, "issue +create") {
|
||||
t.Fatalf("expected prefix 'issue +create', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "--title hello") {
|
||||
t.Fatalf("expected '--title hello' in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "--body world") {
|
||||
t.Fatalf("expected '--body world' in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// formField represents a single field in the argument form.
|
||||
type formField struct {
|
||||
flag common.Flag
|
||||
input textinput.Model
|
||||
boolValue bool
|
||||
isBool bool
|
||||
}
|
||||
|
||||
// formModel is the parameter form sub-component.
|
||||
type formModel struct {
|
||||
group string
|
||||
shortcut *common.Shortcut
|
||||
fields []formField
|
||||
focusIdx int
|
||||
submitted bool
|
||||
cancelled bool
|
||||
values map[string]string
|
||||
width int
|
||||
height int
|
||||
errors []string
|
||||
}
|
||||
|
||||
var formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
var formErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
var formHelpStyle = lipgloss.NewStyle().Faint(true)
|
||||
var formHintStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("11"))
|
||||
var formLabelStyle = lipgloss.NewStyle().Bold(true)
|
||||
var formRequiredMark = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
|
||||
// newFormModel creates a new form model from a shortcut's flags.
|
||||
func newFormModel(group string, shortcut *common.Shortcut, width, height int, prefill map[string]string) formModel {
|
||||
fields := make([]formField, 0, len(shortcut.Flags))
|
||||
|
||||
for _, f := range shortcut.Flags {
|
||||
if f.Bool {
|
||||
// Bool field: no text input, just toggle
|
||||
val := f.Default
|
||||
if v, ok := prefill[f.Name]; ok {
|
||||
val = v
|
||||
}
|
||||
fields = append(fields, formField{
|
||||
flag: f,
|
||||
boolValue: val == "true",
|
||||
isBool: true,
|
||||
})
|
||||
} else {
|
||||
// Text field
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = f.Usage
|
||||
if f.Default != "" {
|
||||
ti.Placeholder = f.Default
|
||||
}
|
||||
ti.Width = width - 30
|
||||
if ti.Width < 20 {
|
||||
ti.Width = 20
|
||||
}
|
||||
|
||||
// Pre-fill from provided values
|
||||
if v, ok := prefill[f.Name]; ok {
|
||||
ti.SetValue(v)
|
||||
}
|
||||
|
||||
fields = append(fields, formField{
|
||||
flag: f,
|
||||
input: ti,
|
||||
isBool: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m := formModel{
|
||||
group: group,
|
||||
shortcut: shortcut,
|
||||
fields: fields,
|
||||
width: width,
|
||||
height: height,
|
||||
errors: []string{},
|
||||
}
|
||||
|
||||
// Focus the first non-bool field, or first field
|
||||
m.focusFirstField()
|
||||
return m
|
||||
}
|
||||
|
||||
// focusFirstField sets focus to the first focusable field.
|
||||
func (m *formModel) focusFirstField() {
|
||||
for i := range m.fields {
|
||||
m.focusIdx = i
|
||||
if !m.fields[i].isBool {
|
||||
m.fields[i].input.Focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// validate checks that all required fields have values.
|
||||
func (m formModel) validate() []string {
|
||||
var errs []string
|
||||
for _, f := range m.fields {
|
||||
if f.flag.Required {
|
||||
if f.isBool {
|
||||
// Bool fields are always valid
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(f.input.Value()) == "" {
|
||||
errs = append(errs, fmt.Sprintf("--%s 为必填参数,不能为空", f.flag.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// collectValues gathers all field values into a map.
|
||||
func (m *formModel) collectValues() map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, f := range m.fields {
|
||||
if f.isBool {
|
||||
if f.boolValue {
|
||||
values[f.flag.Name] = "true"
|
||||
}
|
||||
} else {
|
||||
v := f.input.Value()
|
||||
if v == "" && f.flag.Default != "" {
|
||||
v = f.flag.Default
|
||||
}
|
||||
if v != "" {
|
||||
values[f.flag.Name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Update handles messages for the form.
|
||||
func (m formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
m.cancelled = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.nextField()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyShiftTab, tea.KeyUp:
|
||||
m.prevField()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyEnter:
|
||||
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
|
||||
return m, nil
|
||||
}
|
||||
// Validate
|
||||
errs := m.validate()
|
||||
if len(errs) > 0 {
|
||||
m.errors = errs
|
||||
return m, nil
|
||||
}
|
||||
m.values = m.collectValues()
|
||||
m.submitted = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeySpace:
|
||||
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
|
||||
return m, nil
|
||||
}
|
||||
// Fall through to text input
|
||||
}
|
||||
|
||||
// Forward to focused text input
|
||||
if len(m.fields) > 0 && !m.fields[m.focusIdx].isBool {
|
||||
var cmd tea.Cmd
|
||||
m.fields[m.focusIdx].input, cmd = m.fields[m.focusIdx].input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// nextField moves focus to the next field.
|
||||
func (m *formModel) nextField() {
|
||||
if len(m.fields) == 0 {
|
||||
return
|
||||
}
|
||||
// Blur current
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Blur()
|
||||
}
|
||||
m.focusIdx = (m.focusIdx + 1) % len(m.fields)
|
||||
// Focus new
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// prevField moves focus to the previous field.
|
||||
func (m *formModel) prevField() {
|
||||
if len(m.fields) == 0 {
|
||||
return
|
||||
}
|
||||
// Blur current
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Blur()
|
||||
}
|
||||
m.focusIdx--
|
||||
if m.focusIdx < 0 {
|
||||
m.focusIdx = len(m.fields) - 1
|
||||
}
|
||||
// Focus new
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (m formModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Title
|
||||
title := formTitleStyle.Render(fmt.Sprintf("%s +%s", m.group, m.shortcut.Name))
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Fields
|
||||
for i, f := range m.fields {
|
||||
label := f.flag.Name
|
||||
if f.flag.Required {
|
||||
label = label + formRequiredMark.Render(" *")
|
||||
}
|
||||
|
||||
if f.isBool {
|
||||
// Bool field
|
||||
check := "[ ]"
|
||||
if f.boolValue {
|
||||
check = "[✓]"
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s", check, label)
|
||||
if i == m.focusIdx {
|
||||
line = selectedStyle.Render(line)
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
if f.flag.Usage != "" {
|
||||
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
// Text field
|
||||
prompt := fmt.Sprintf(" %s: ", label)
|
||||
if i == m.focusIdx {
|
||||
prompt = formLabelStyle.Render(prompt)
|
||||
}
|
||||
sb.WriteString(prompt)
|
||||
sb.WriteString(f.input.View())
|
||||
sb.WriteString("\n")
|
||||
if f.flag.Usage != "" {
|
||||
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Errors
|
||||
if len(m.errors) > 0 {
|
||||
sb.WriteString("\n")
|
||||
for _, e := range m.errors {
|
||||
sb.WriteString(formErrorStyle.Render(fmt.Sprintf(" ✗ %s", e)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Help
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(formHelpStyle.Render(" Enter submit · Tab/↑↓ next field · Shift+Tab prev field · Space toggle bool · Esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
)
|
||||
|
||||
// NewInteractiveCmd creates the Cobra command for the interactive REPL.
|
||||
func NewInteractiveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "interactive",
|
||||
Short: "Start interactive REPL with command palette",
|
||||
Long: `Start an interactive REPL session with a command palette.
|
||||
|
||||
Type / to open the command palette with fuzzy search.
|
||||
Use arrow keys to navigate, Enter to select, Tab to fill parameters.
|
||||
Type "exit" or press Ctrl+D to quit.`,
|
||||
Aliases: []string{"i"},
|
||||
Example: ` gitlink interactive
|
||||
gitlink i`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 交互模式默认用表格输出,更易阅读;用户显式指定 --format 时优先采用用户的值。
|
||||
if cmdutil.Format == "" {
|
||||
cmdutil.Format = "table"
|
||||
}
|
||||
owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if err != nil {
|
||||
owner = cmdutil.Owner
|
||||
repo = cmdutil.Repo
|
||||
}
|
||||
return Run(owner, repo)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// makeShortcutForViewport builds a minimal shortcut so formatCommandDisplay has
|
||||
// something to render in the display header line of the output viewer.
|
||||
func makeShortcutForViewport() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "list",
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongOutputEntersViewport verifies that when command output exceeds the
|
||||
// available terminal height, the REPL switches into the scrollable output
|
||||
// viewer (stateOutput) instead of falling back to the inline input state.
|
||||
func TestLongOutputEntersViewport(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
// Small terminal: m.height-4 == 1 line of room, so any multi-line output
|
||||
// must overflow into the viewport.
|
||||
m.height = 5
|
||||
m.width = 80
|
||||
m.state = stateExecuting
|
||||
|
||||
longOutput := strings.Repeat("line of output\n", 20)
|
||||
msg := execResultMsg{
|
||||
group: "issue",
|
||||
shortcut: makeShortcutForViewport(),
|
||||
args: map[string]string{},
|
||||
output: longOutput,
|
||||
err: nil,
|
||||
}
|
||||
|
||||
newModel, _ := m.Update(msg)
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
|
||||
if repl.state != stateOutput {
|
||||
t.Fatalf("expected stateOutput for long output, got %v", repl.state)
|
||||
}
|
||||
if repl.viewport.Height != m.height-2 {
|
||||
t.Fatalf("expected viewport height %d, got %d", m.height-2, repl.viewport.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortOutputStaysInput verifies that short command output keeps the
|
||||
// existing behaviour: store lastOutput and return to stateInput.
|
||||
func TestShortOutputStaysInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.height = 40 // plenty of room
|
||||
m.width = 80
|
||||
m.state = stateExecuting
|
||||
|
||||
shortOutput := "only one line"
|
||||
msg := execResultMsg{
|
||||
group: "issue",
|
||||
shortcut: makeShortcutForViewport(),
|
||||
args: map[string]string{},
|
||||
output: shortOutput,
|
||||
err: nil,
|
||||
}
|
||||
|
||||
newModel, _ := m.Update(msg)
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput for short output, got %v", repl.state)
|
||||
}
|
||||
if repl.lastOutput == "" {
|
||||
t.Fatalf("expected lastOutput to be set for short output")
|
||||
}
|
||||
if !strings.Contains(repl.lastOutput, shortOutput) {
|
||||
t.Fatalf("expected lastOutput to contain %q, got %q", shortOutput, repl.lastOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewportQuitReturnsToInput verifies that pressing 'q' in the output
|
||||
// viewer returns the REPL to the input state.
|
||||
func TestViewportQuitReturnsToInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.state = stateOutput
|
||||
|
||||
keyQ := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
|
||||
newModel, _ := m.Update(keyQ)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput after pressing 'q', got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewportEscReturnsToInput verifies that Esc also exits the output viewer.
|
||||
func TestViewportEscReturnsToInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.state = stateOutput
|
||||
|
||||
keyEsc := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
newModel, _ := m.Update(keyEsc)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput after pressing Esc, got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// paletteLevel tracks which level of the palette we are browsing.
|
||||
type paletteLevel int
|
||||
|
||||
const (
|
||||
levelGroup paletteLevel = iota // browsing command groups
|
||||
levelCommand // browsing commands within a group
|
||||
)
|
||||
|
||||
// PaletteResult holds the user's selection from the palette.
|
||||
type PaletteResult struct {
|
||||
Group string
|
||||
Shortcut *common.Shortcut
|
||||
}
|
||||
|
||||
// commandItem implements list.Item and list.DefaultItem.
|
||||
type commandItem struct {
|
||||
title string
|
||||
description string
|
||||
shortcut *common.Shortcut // nil for group items
|
||||
group string // empty for group items
|
||||
}
|
||||
|
||||
func (i commandItem) Title() string { return i.title }
|
||||
func (i commandItem) Description() string { return i.description }
|
||||
func (i commandItem) FilterValue() string { return i.title + " " + i.description }
|
||||
|
||||
// paletteModel is the command palette sub-component.
|
||||
type paletteModel struct {
|
||||
level paletteLevel
|
||||
search textinput.Model
|
||||
list list.Model
|
||||
groups map[string][]*common.Shortcut
|
||||
descs map[string]string
|
||||
groupKeys []string
|
||||
selected string // currently selected group name (when levelCommand)
|
||||
result *PaletteResult
|
||||
quitting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
var paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
var selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||
var paletteHelpStyle = lipgloss.NewStyle().Faint(true)
|
||||
|
||||
// newPaletteModel creates a new palette model.
|
||||
func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel {
|
||||
search := textinput.New()
|
||||
search.Prompt = "> "
|
||||
search.Placeholder = "Search..."
|
||||
search.Focus()
|
||||
search.Width = width - 4
|
||||
|
||||
// Sort group keys for deterministic ordering
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// Build group items
|
||||
items := make([]list.Item, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
desc := descs[k]
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
delegate := list.NewDefaultDelegate()
|
||||
l := list.New(items, delegate, width, height-4)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowFilter(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
|
||||
return paletteModel{
|
||||
level: levelGroup,
|
||||
search: search,
|
||||
list: l,
|
||||
groups: groups,
|
||||
descs: descs,
|
||||
groupKeys: keys,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// switchToCommands switches the list to show commands for the given group.
|
||||
func (m *paletteModel) switchToCommands(group string) {
|
||||
m.selected = group
|
||||
m.level = levelCommand
|
||||
|
||||
shortcuts := m.groups[group]
|
||||
items := make([]list.Item, 0, len(shortcuts))
|
||||
for _, s := range shortcuts {
|
||||
items = append(items, commandItem{
|
||||
title: "+" + s.Name,
|
||||
description: s.Description,
|
||||
shortcut: s,
|
||||
group: group,
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
m.search.SetValue("")
|
||||
}
|
||||
|
||||
// backToGroups returns to the group list.
|
||||
func (m *paletteModel) backToGroups() {
|
||||
m.level = levelGroup
|
||||
m.selected = ""
|
||||
|
||||
items := make([]list.Item, 0, len(m.groupKeys))
|
||||
for _, k := range m.groupKeys {
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: m.descs[k],
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
m.search.SetValue("")
|
||||
}
|
||||
|
||||
// filterItems filters the current list items by the given query.
|
||||
func (m *paletteModel) filterItems(query string) {
|
||||
var items []list.Item
|
||||
|
||||
if m.level == levelGroup {
|
||||
for _, k := range m.groupKeys {
|
||||
desc := m.descs[k]
|
||||
if matchesQuery(k, desc, query) {
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: desc,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shortcuts := m.groups[m.selected]
|
||||
for _, s := range shortcuts {
|
||||
name := "+" + s.Name
|
||||
if matchesQuery(name, s.Description, query) {
|
||||
items = append(items, commandItem{
|
||||
title: name,
|
||||
description: s.Description,
|
||||
shortcut: s,
|
||||
group: m.selected,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []list.Item{}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
}
|
||||
|
||||
// matchesQuery checks if the query is a subsequence (fzy-style fuzzy match)
|
||||
// of either the title or the description, case-insensitively. An empty query
|
||||
// matches everything.
|
||||
func matchesQuery(title, description, query string) bool {
|
||||
return subsequenceMatch(query, title) || subsequenceMatch(query, description)
|
||||
}
|
||||
|
||||
// subsequenceMatch returns true if every character of query appears in target
|
||||
// in the same order (not necessarily contiguously), ignoring case. An empty
|
||||
// query always matches.
|
||||
func subsequenceMatch(query, target string) bool {
|
||||
query = strings.ToLower(query)
|
||||
target = strings.ToLower(target)
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
i := 0
|
||||
for j := 0; j < len(target) && i < len(query); j++ {
|
||||
if target[j] == query[i] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i == len(query)
|
||||
}
|
||||
|
||||
// Update handles messages for the palette.
|
||||
func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
if m.level == levelCommand {
|
||||
m.backToGroups()
|
||||
return m, nil
|
||||
}
|
||||
// levelGroup: signal the outer REPL to return to stateInput, not quit
|
||||
m.quitting = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyEnter:
|
||||
selected := m.list.SelectedItem()
|
||||
if selected == nil {
|
||||
return m, nil
|
||||
}
|
||||
item := selected.(commandItem)
|
||||
if m.level == levelGroup {
|
||||
m.switchToCommands(item.title)
|
||||
return m, nil
|
||||
}
|
||||
// levelCommand: set result
|
||||
m.result = &PaletteResult{
|
||||
Group: m.selected,
|
||||
Shortcut: item.shortcut,
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyUp, tea.KeyDown:
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
default:
|
||||
// Forward to search input
|
||||
var cmd tea.Cmd
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
m.filterItems(m.search.Value())
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetWidth(msg.Width)
|
||||
m.list.SetHeight(msg.Height - 4)
|
||||
m.search.Width = msg.Width - 4
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the palette.
|
||||
func (m paletteModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
var title string
|
||||
if m.level == levelGroup {
|
||||
title = paletteTitleStyle.Render("Command Palette — Select a group")
|
||||
} else {
|
||||
title = paletteTitleStyle.Render(fmt.Sprintf("Command Palette — %s", m.selected))
|
||||
}
|
||||
|
||||
searchView := m.search.View()
|
||||
|
||||
// Render list items manually for better control
|
||||
var items strings.Builder
|
||||
listItems := m.list.Items()
|
||||
idx := m.list.Index()
|
||||
|
||||
for i, item := range listItems {
|
||||
ci := item.(commandItem)
|
||||
if i == idx {
|
||||
items.WriteString(selectedStyle.Render(fmt.Sprintf(" ❯ %s", ci.title)))
|
||||
if ci.description != "" {
|
||||
items.WriteString(" ")
|
||||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||||
}
|
||||
} else {
|
||||
items.WriteString(fmt.Sprintf(" %s", ci.title))
|
||||
if ci.description != "" {
|
||||
items.WriteString(" ")
|
||||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||||
}
|
||||
}
|
||||
items.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(listItems) == 0 {
|
||||
items.WriteString(paletteHelpStyle.Render(" No matches found"))
|
||||
items.WriteString("\n")
|
||||
}
|
||||
|
||||
help := paletteHelpStyle.Render("Enter select · Esc back · ↑↓ navigate · type to search")
|
||||
|
||||
return fmt.Sprintf("%s\n%s\n%s\n%s", title, searchView, items.String(), help)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestPaletteSelectionOpensFormForOptionalFlags 验证:选中一个"只有可选参数、
|
||||
// 无必填参数"的命令(如 branch +batch-protect 的 names/from 二选一)时,
|
||||
// 仍然弹出参数表单,而非直接执行导致 "no ... provided" 错误。
|
||||
//
|
||||
// 回归:之前 updatePalette 只在有缺失的 Required flag 时才弹表单,
|
||||
// 导致 names/from 这类"至少一个"型参数(无法单独标 Required)的命令
|
||||
// 被直接执行,用户没机会输入参数。
|
||||
func TestPaletteSelectionOpensFormForOptionalFlags(t *testing.T) {
|
||||
// 构造一个模拟 branch +batch-protect 的 shortcut:所有 flag 都非必填。
|
||||
shortcuts := map[string][]*common.Shortcut{
|
||||
"branch": {
|
||||
{
|
||||
Name: "batch-protect",
|
||||
Description: "批量保护分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Usage: "分支名(逗号分隔)"}, // 无 Required
|
||||
{Name: "from", Usage: "文件路径"}, // 无 Required
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error { return nil },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := newReplModel("owner", "repo")
|
||||
// 注入测试 registry
|
||||
m.shortcuts = shortcuts
|
||||
|
||||
// 1) 输入 / 触发命令面板
|
||||
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
repl := m2.(replModel)
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("输入 / 后应在 statePalette,实际 %v", repl.state)
|
||||
}
|
||||
|
||||
// 2) 模拟用户选中 branch 组
|
||||
repl.palette.result = &PaletteResult{
|
||||
Group: "branch",
|
||||
Shortcut: shortcuts["branch"][0],
|
||||
}
|
||||
|
||||
// 3) 派发一条任意消息触发 updatePalette 的结果检查
|
||||
m3, _ := repl.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
final := m3.(replModel)
|
||||
|
||||
if final.state != stateForm {
|
||||
t.Fatalf("选中带可选参数的命令应进入 stateForm,实际 %v(命令被直接执行了,用户没机会填参数)", final.state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// replState tracks the current state of the REPL.
|
||||
type replState int
|
||||
|
||||
const (
|
||||
stateInput replState = iota // waiting for user text input
|
||||
statePalette // command palette is active
|
||||
stateForm // parameter form is active
|
||||
stateExecuting // executing a command
|
||||
stateError // showing an error
|
||||
stateOutput // scrolling through long command output
|
||||
)
|
||||
|
||||
// execResultMsg is sent when command execution completes.
|
||||
type execResultMsg struct {
|
||||
group string
|
||||
shortcut *common.Shortcut
|
||||
args map[string]string
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
// replModel is the main REPL state machine.
|
||||
type replModel struct {
|
||||
state replState
|
||||
input textinput.Model
|
||||
palette paletteModel
|
||||
form formModel
|
||||
executor Executor
|
||||
shortcuts map[string][]*common.Shortcut
|
||||
descs map[string]string
|
||||
lastOutput string
|
||||
lastError string
|
||||
spinner spinner.Model
|
||||
viewport viewport.Model // 可滚动查看器,用于浏览超出一屏的长输出
|
||||
owner string
|
||||
repo string
|
||||
width int
|
||||
height int
|
||||
quitting bool
|
||||
showWelcome bool
|
||||
}
|
||||
|
||||
var promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
||||
var successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
var errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
var welcomeStyle = lipgloss.NewStyle().Faint(true)
|
||||
|
||||
// newReplModel creates a new REPL model.
|
||||
func newReplModel(owner, repo string) replModel {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = buildPrompt(owner, repo)
|
||||
ti.Focus()
|
||||
ti.Width = 60
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
|
||||
|
||||
return replModel{
|
||||
state: stateInput,
|
||||
input: ti,
|
||||
executor: Executor{},
|
||||
shortcuts: shortcuts.GetAllShortcuts(),
|
||||
descs: shortcuts.GetDescriptions(),
|
||||
spinner: sp,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
showWelcome: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildPrompt creates the prompt string.
|
||||
func buildPrompt(owner, repo string) string {
|
||||
if owner != "" && repo != "" {
|
||||
return fmt.Sprintf("gitlink (%s/%s)> ", owner, repo)
|
||||
}
|
||||
return "gitlink> "
|
||||
}
|
||||
|
||||
// Init initializes the REPL.
|
||||
func (m replModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update is the main message dispatcher.
|
||||
func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
wmsg := msg.(tea.WindowSizeMsg)
|
||||
m.width = wmsg.Width
|
||||
m.height = wmsg.Height
|
||||
// 保持 viewport 尺寸与终端同步(底部留 2 行给提示符/帮助行)。
|
||||
m.viewport.Width = wmsg.Width
|
||||
m.viewport.Height = wmsg.Height - 2
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateInput:
|
||||
return m.updateInput(msg)
|
||||
case statePalette:
|
||||
return m.updatePalette(msg)
|
||||
case stateForm:
|
||||
return m.updateForm(msg)
|
||||
case stateExecuting:
|
||||
return m.updateExecuting(msg)
|
||||
case stateError:
|
||||
return m.updateError(msg)
|
||||
case stateOutput:
|
||||
return m.updateOutput(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateInput handles the text input state.
|
||||
func (m replModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, isKey := msg.(tea.KeyMsg)
|
||||
if isKey {
|
||||
switch keyMsg.Type {
|
||||
case tea.KeyRunes:
|
||||
// Trigger the command palette immediately when the user types "/"
|
||||
// as the first character of a fresh input — no Enter required.
|
||||
if m.input.Value() == "" && len(keyMsg.Runes) > 0 && keyMsg.Runes[0] == '/' {
|
||||
m.showWelcome = false
|
||||
m.input.SetValue("")
|
||||
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
|
||||
// Carry any extra characters typed alongside "/" as the initial query.
|
||||
if len(keyMsg.Runes) > 1 {
|
||||
query := string(keyMsg.Runes[1:])
|
||||
m.palette.search.SetValue(query)
|
||||
m.palette.filterItems(query)
|
||||
}
|
||||
m.state = statePalette
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case tea.KeyEnter:
|
||||
val := strings.TrimSpace(m.input.Value())
|
||||
m.input.SetValue("")
|
||||
m.showWelcome = false
|
||||
|
||||
if val == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Exit commands
|
||||
if val == "exit" || val == "quit" {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// Palette mode (also reachable via "/query" + Enter)
|
||||
if strings.HasPrefix(val, "/") {
|
||||
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
|
||||
if len(val) > 1 {
|
||||
query := val[1:]
|
||||
m.palette.search.SetValue(query)
|
||||
m.palette.filterItems(query)
|
||||
}
|
||||
m.state = statePalette
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Direct command: "group +cmd ..."
|
||||
group, cmd, flagStr, ok := parseDirectCommand(val)
|
||||
if ok {
|
||||
s, found := findShortcut(m.shortcuts, group, cmd)
|
||||
if !found {
|
||||
m.lastError = fmt.Sprintf("Unknown command: %s +%s", group, cmd)
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
}
|
||||
|
||||
shortMap := buildShortMap(s)
|
||||
args := parseFlagString(flagStr, shortMap)
|
||||
missing := missingRequiredFlags(s, args)
|
||||
|
||||
if len(missing) > 0 {
|
||||
// Show form with prefilled values
|
||||
m.form = newFormModel(group, s, m.width, m.height, args)
|
||||
m.state = stateForm
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Execute directly
|
||||
m.state = stateExecuting
|
||||
return m, m.executeCommand(group, s, args)
|
||||
}
|
||||
|
||||
// Unrecognized input
|
||||
m.lastError = fmt.Sprintf("Unknown input: %q\nType a command like \"issue +list\" or \"/\" to open the command palette.", val)
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
|
||||
case tea.KeyCtrlD, tea.KeyCtrlC:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to text input
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// executeCommand returns a tea.Cmd that runs a shortcut asynchronously.
|
||||
func (m replModel) executeCommand(group string, s *common.Shortcut, args map[string]string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
output, err := m.executor.Execute(s, args)
|
||||
return execResultMsg{
|
||||
group: group,
|
||||
shortcut: s,
|
||||
args: args,
|
||||
output: output,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updatePalette delegates to the palette sub-component.
|
||||
func (m replModel) updatePalette(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.palette, cmd = m.palette.Update(msg)
|
||||
|
||||
if m.palette.quitting {
|
||||
m.palette.quitting = false
|
||||
m.state = stateInput
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
if m.palette.result != nil {
|
||||
result := m.palette.result
|
||||
s := result.Shortcut
|
||||
|
||||
// Open the form whenever the command has ANY flags — not just required
|
||||
// ones. Many commands have "at-least-one-of" parameters (e.g. branch
|
||||
// +batch-protect needs --names OR --from) that can't be marked Required
|
||||
// individually, so skipping the form for "optional-only" commands left
|
||||
// users unable to supply them and the command failed at runtime.
|
||||
// Commands with no flags at all execute directly.
|
||||
if len(s.Flags) > 0 {
|
||||
m.form = newFormModel(result.Group, s, m.width, m.height, nil)
|
||||
m.state = stateForm
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// No flags → execute directly
|
||||
m.state = stateExecuting
|
||||
return m, tea.Batch(m.executeCommand(result.Group, s, map[string]string{}), m.spinner.Tick)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateForm delegates to the form sub-component.
|
||||
func (m replModel) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.form, cmd = m.form.Update(msg)
|
||||
|
||||
if m.form.cancelled {
|
||||
m.form.cancelled = false
|
||||
m.state = stateInput
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
if m.form.submitted {
|
||||
args := m.form.values
|
||||
group := m.form.group
|
||||
s := m.form.shortcut
|
||||
m.form.submitted = false
|
||||
m.state = stateExecuting
|
||||
return m, m.executeCommand(group, s, args)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateExecuting waits for the execResultMsg.
|
||||
func (m replModel) updateExecuting(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case execResultMsg:
|
||||
if msg.err != nil {
|
||||
m.lastError = fmt.Sprintf("命令执行失败: %s", msg.err)
|
||||
m.lastOutput = ""
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
}
|
||||
display := formatCommandDisplay(msg.group, msg.shortcut, msg.args)
|
||||
content := fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output)
|
||||
// 输出超过可用高度(留 4 行给提示符与边距)时进入可滚动查看器,
|
||||
// 否则保持原行为:直接显示在输入状态。
|
||||
lineCount := strings.Count(content, "\n") + 1
|
||||
if m.height > 0 && lineCount > m.height-4 {
|
||||
m.viewport = viewport.New(m.width, m.height-2)
|
||||
m.viewport.SetContent(content)
|
||||
m.viewport.GotoTop()
|
||||
m.lastOutput = ""
|
||||
m.lastError = ""
|
||||
m.state = stateOutput
|
||||
return m, nil
|
||||
}
|
||||
m.lastOutput = content
|
||||
m.lastError = ""
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateError waits for Enter to return to input state.
|
||||
func (m replModel) updateError(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, isKey := msg.(tea.KeyMsg)
|
||||
if isKey {
|
||||
switch keyMsg.Type {
|
||||
case tea.KeyEnter, tea.KeyEsc:
|
||||
m.lastError = ""
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
case tea.KeyCtrlD, tea.KeyCtrlC:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateOutput 处理输出查看器状态:用方向键/PageUp/PageDown 浏览长输出,
|
||||
// 按 q 或 Esc 返回输入状态。
|
||||
func (m replModel) updateOutput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc, tea.KeyCtrlC:
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
case tea.KeyRunes:
|
||||
// q / Q 返回输入
|
||||
if len(msg.Runes) > 0 && (msg.Runes[0] == 'q' || msg.Runes[0] == 'Q') {
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其余按键(方向键、PageUp/Down、Home/End)转发给 viewport,
|
||||
// viewport 内置响应这些按键,无需手动处理。
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View renders the REPL based on current state.
|
||||
func (m replModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateInput:
|
||||
var sb strings.Builder
|
||||
if m.showWelcome {
|
||||
sb.WriteString(welcomeStyle.Render("Welcome to gitlink-cli interactive mode."))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(welcomeStyle.Render("Type / to open the command palette, \"exit\" or Ctrl+D to quit."))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if m.lastOutput != "" {
|
||||
sb.WriteString(successStyle.Render(m.lastOutput))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(m.input.View())
|
||||
return sb.String()
|
||||
|
||||
case statePalette:
|
||||
return m.palette.View()
|
||||
|
||||
case stateForm:
|
||||
return m.form.View()
|
||||
|
||||
case stateExecuting:
|
||||
return fmt.Sprintf("\n %s Executing command...\n", m.spinner.View())
|
||||
|
||||
case stateError:
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(errorStyle.Render(m.lastError))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(formHelpStyle.Render(" Press Enter to continue"))
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
|
||||
case stateOutput:
|
||||
return m.viewport.View() + "\n" +
|
||||
formHelpStyle.Render(" ↑↓/PgUp/PgDn 滚动 q/Esc 返回")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run starts the interactive REPL.
|
||||
func Run(owner, repo string) error {
|
||||
m := newReplModel(owner, repo)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// TestSlashTriggersPaletteImmediately verifies that typing "/" as the first
|
||||
// character (KeyRunes) switches the REPL into the palette state without
|
||||
// requiring Enter. This is the regression test for the bug where the
|
||||
// command palette never appeared.
|
||||
func TestSlashTriggersPaletteImmediately(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
if m.state != stateInput {
|
||||
t.Fatalf("expected initial state stateInput, got %v", m.state)
|
||||
}
|
||||
|
||||
// Simulate the user pressing the "/" key.
|
||||
// bubbletea delivers printable chars as KeyRunes with the runes populated.
|
||||
keySlash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
||||
newModel, _ := m.Update(keySlash)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("expected state statePalette after typing '/', got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashWithQueryCarriesIntoSearch verifies "/iss" pre-fills the palette search.
|
||||
func TestSlashWithQueryCarriesIntoSearch(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
|
||||
keySlashQuery := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/', 'i', 's', 's'}}
|
||||
newModel, _ := m.Update(keySlashQuery)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("expected state statePalette, got %v", repl.state)
|
||||
}
|
||||
if got := repl.palette.search.Value(); got != "iss" {
|
||||
t.Fatalf("expected palette search 'iss', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonSlashInputStaysInInputState verifies ordinary text doesn't open the palette.
|
||||
func TestNonSlashInputStaysInInputState(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
|
||||
keyA := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
|
||||
newModel, _ := m.Update(keyA)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("typing 'a' should stay in stateInput, got %v", repl.state)
|
||||
}
|
||||
}
|
||||
10
cmd/root.go
10
cmd/root.go
|
|
@ -10,17 +10,23 @@ import (
|
|||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var colorMode string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
cmdutil.ColorMode = colorMode
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
@ -28,6 +34,8 @@ func init() {
|
|||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
rootCmd.PersistentFlags().StringVar(&colorMode, "color", "auto", "Color output: auto, always, never")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.AutoConfirm, "yes", false, "Skip confirmation prompts")
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
|
|
@ -35,6 +43,8 @@ func init() {
|
|||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
|
||||
rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
- `webhook +view`
|
||||
- `webhook +update`
|
||||
- `webhook +delete`
|
||||
- `webhook +test`
|
||||
|
||||
同时补充了对应单元测试、帮助文档和示例说明。
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink CLI Skills 交互式演示</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Microsoft YaHei", sans-serif; background: #f0f2f5; color: #333; }
|
||||
.header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 40px 20px; text-align: center; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
.header p { color: #a0aec0; font-size: 15px; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 24px 20px; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.tab { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-size: 14px; background: #e2e8f0; color: #4a5568; transition: all 0.2s; }
|
||||
.tab:hover { background: #cbd5e0; }
|
||||
.tab.active { background: #1a73e8; color: #fff; }
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px 24px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.card h3 { font-size: 18px; color: #1a1a2e; margin-bottom: 8px; }
|
||||
.card p { font-size: 14px; color: #555; line-height: 1.7; }
|
||||
.card .tag { display: inline-block; background: #e8f0fe; color: #1a73e8; padding: 3px 12px; border-radius: 12px; font-size: 12px; margin: 2px 4px 2px 0; }
|
||||
.step { margin-bottom: 12px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
|
||||
.step-hd { display: flex; align-items: center; padding: 12px 16px; cursor: pointer; background: #fafbfc; }
|
||||
.step-hd:hover { background: #f0f2f5; }
|
||||
.step-num { width: 26px; height: 26px; background: #1a73e8; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-hd .title { flex: 1; font-size: 14px; font-weight: 600; }
|
||||
.step-hd .arrow { font-size: 14px; color: #999; transition: transform 0.2s; }
|
||||
.step-hd.open .arrow { transform: rotate(90deg); }
|
||||
.step-bd { display: none; padding: 16px; }
|
||||
.step-bd.open { display: block; }
|
||||
.cmd { background: #1e1e2e; color: #cdd6f4; padding: 10px 14px; border-radius: 6px; font-family: Consolas,monospace; font-size: 12px; overflow-x: auto; margin-bottom: 10px; }
|
||||
.cmd .prompt { color: #89b4fa; }
|
||||
.output { background: #f8f9fa; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 14px; font-family: Consolas,monospace; font-size: 12px; overflow-x: auto; white-space: pre-wrap; color: #333; max-height: 250px; overflow-y: auto; margin-bottom: 10px; }
|
||||
.analysis { background: #fefce8; border: 1px solid #fde68a; border-radius: 6px; padding: 14px; font-size: 13px; color: #92400e; margin-top: 8px; line-height: 1.8; }
|
||||
.analysis strong { color: #78350f; }
|
||||
.report { background: #fff; border: 2px solid #1a73e8; border-radius: 8px; padding: 20px; font-size: 13px; line-height: 2; margin-top: 8px; }
|
||||
.report h4 { font-size: 18px; margin-bottom: 10px; color: #1a1a2e; }
|
||||
.report table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; }
|
||||
.report th { background: #e8f0fe; color: #1a56db; padding: 6px 10px; text-align: left; border: 1px solid #d0d7de; }
|
||||
.report td { padding: 6px 10px; border: 1px solid #d0d7de; }
|
||||
.badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-g { background: #dcfce7; color: #166534; }
|
||||
.badge-y { background: #fef9c3; color: #854d0e; }
|
||||
.badge-r { background: #fce4ec; color: #c62828; }
|
||||
.file-link { color: #1a73e8; text-decoration: none; font-size: 13px; }
|
||||
.file-link:hover { text-decoration: underline; }
|
||||
.bar { display: inline-block; height: 14px; border-radius: 3px; margin-right: 4px; vertical-align: middle; }
|
||||
@media (max-width: 768px) { .tabs { flex-direction: column; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>GitLink CLI Skills 交互式演示</h1>
|
||||
<p>点击查看 AI 对数据的深度分析与处理结果</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="health">项目健康度报告</button>
|
||||
<button class="tab" data-tab="release">Release Notes 生成</button>
|
||||
<button class="tab" data-tab="triage">Issue 自动分拣</button>
|
||||
</div>
|
||||
|
||||
<!-- ======================== 项目健康度 ======================== -->
|
||||
<div class="panel active" id="panel-health">
|
||||
<div class="card">
|
||||
<h3>项目健康度报告</h3>
|
||||
<p>采集 Issue、PR、commit 数据,从 7 个维度量化分析,生成综合评分和改进建议。</p>
|
||||
<div><span class="tag">repo +info</span><span class="tag">issue +list</span><span class="tag">pr +list</span><span class="tag">commit +list</span><span class="tag">milestone +list</span></div>
|
||||
<a class="file-link" href="../skills/gitlink-project-health/SKILL.md" target="_blank">查看 SKILL.md →</a>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">采集原始数据</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="cmd"><span class="prompt">$</span> gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json</div>
|
||||
<div class="output">{
|
||||
"issues": [
|
||||
{"number":1,"subject":"提升跨平台兼容性","status_id":1,"created_at":"2026-05-28"},
|
||||
{"number":2,"subject":"keyword-test-xyz-abc","status_id":5,"created_at":"2026-05-28","closed_at":"2026-06-01"},
|
||||
{"number":10,"subject":"新增 Wiki 管理","status_id":5,"created_at":"2026-06-01","closed_at":"2026-06-04"},
|
||||
{"number":12,"subject":"新增代码片段管理","status_id":5,"created_at":"2026-06-03","closed_at":"2026-06-04"},
|
||||
{"number":14,"subject":"Webhook 投递监控","status_id":1,"created_at":"2026-06-04"},
|
||||
{"number":15,"subject":"新增 failed + task-view","status_id":1,"created_at":"2026-06-04"}
|
||||
],
|
||||
"total_count":16, "closed_count":14
|
||||
}</div>
|
||||
<div class="cmd"><span class="prompt">$</span> gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json</div>
|
||||
<div class="output">{
|
||||
"pull_requests": [
|
||||
{"number":31,"title":"fix: normalize issue list output","state":"merged","created_at":"2026-06-01","merged_at":"2026-06-03"}
|
||||
],
|
||||
"total_count":5, "merged_count":4
|
||||
}</div>
|
||||
<div class="cmd"><span class="prompt">$</span> gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 50</div>
|
||||
<div class="output">[
|
||||
{"author":"wqer","message":"enhance: enrich skill outputs with data analysis","date":"2026-06-17"},
|
||||
{"author":"wqer","message":"docs: update demo page with AI analysis","date":"2026-06-17"},
|
||||
{"author":"wqer","message":"docs: add interactive demo page for skills","date":"2026-06-17"},
|
||||
{"author":"wqer","message":"docs: add release notes workflow example","date":"2026-06-17"},
|
||||
{"author":"wqer","message":"feat: add three new skills","date":"2026-06-17"}
|
||||
]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">Issue 维度深度分析</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>📊 Issue 基础统计</strong><br>
|
||||
总 Issue 数:<strong>16</strong> 个<br>
|
||||
打开中:<strong>2</strong> 个(12.5%)<br>
|
||||
已关闭:<strong>14</strong> 个(87.5%)<br><br>
|
||||
|
||||
<strong>⏱ 响应时间分析</strong><br>
|
||||
计算每条 closed Issue 从 created 到 closed 的天数:<br>
|
||||
#2 keyword-test: 4 天<br>
|
||||
#10 Wiki 管理: 3 天<br>
|
||||
#12 代码片段: 1 天<br>
|
||||
平均响应时间:<strong>2.7 天</strong> → <span class="badge badge-g">响应及时 🟢</span><br><br>
|
||||
|
||||
<strong>📈 积压趋势分析</strong><br>
|
||||
近 30 天新增:4 个<br>
|
||||
近 30 天关闭:3 个<br>
|
||||
净变化:<strong>+1</strong> → <span class="badge badge-y">基本稳定 🟡</span><br><br>
|
||||
|
||||
<strong>🏷 标签分布</strong><br>
|
||||
未打标签:16 个(100%)→ 建议增加标签管理<br><br>
|
||||
|
||||
<strong>📋 小结</strong><br>
|
||||
关闭率 87.5%,响应及时,积压稳定 → <span class="badge badge-g">Issue 管理良好 🟢</span><br>
|
||||
<strong>评分:8/10</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">PR 维度深度分析</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>📊 PR 基础统计</strong><br>
|
||||
总 PR 数:<strong>5</strong> 个<br>
|
||||
已合并:<strong>4</strong> 个(80%)<br>
|
||||
打开中:<strong>1</strong> 个(20%)<br><br>
|
||||
|
||||
<strong>⏱ 合并效率分析</strong><br>
|
||||
#31 fix: normalize output — 2 天<br>
|
||||
平均合并时间:<strong>2 天</strong> → <span class="badge badge-g">合并迅速 🟢</span><br><br>
|
||||
|
||||
<strong>📋 小结</strong><br>
|
||||
合并率高,速度快 → <span class="badge badge-g">PR 流程健康 🟢</span><br>
|
||||
<strong>评分:8/10</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">4</span><span class="title">贡献者活跃度分析</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>👥 贡献者统计</strong><br>
|
||||
近 50 次提交:<strong>15</strong> 次<br>
|
||||
去重作者:<strong>1</strong> 人(wqer) → <span class="badge badge-r">单人维护 🔴</span><br>
|
||||
新贡献者:0 人 → <span class="badge badge-r">缺少新鲜血液 🔴</span><br><br>
|
||||
|
||||
<strong>📅 提交频率</strong><br>
|
||||
最近一周:每天都有提交 → <span class="badge badge-g">非常活跃 🟢</span><br><br>
|
||||
|
||||
<strong>🔄 提交类型分布</strong><br>
|
||||
feat(新功能):6 次(40%)<br>
|
||||
docs(文档):5 次(33%)<br>
|
||||
fix(修复):2 次(13%)<br>
|
||||
chore(工程):2 次(13%)<br><br>
|
||||
|
||||
<strong>📋 小结</strong><br>
|
||||
提交活跃但仅 1 人维护 → <span class="badge badge-y">需吸引贡献者 🟡</span><br>
|
||||
<strong>评分:6/10</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">5</span><span class="title">里程碑进度</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>🗓 里程碑概览</strong><br>
|
||||
v1.3.0 — 到期日:2026-06-30,完成度:60% → <span class="badge badge-g">进行中 🟢</span><br>
|
||||
v2.0.0 — 到期日:2026-08-15,完成度:20% → <span class="badge badge-y">初期阶段 🟡</span><br><br>
|
||||
<strong>逾期风险:</strong>无 🔴 无逾期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)" style="background:#f0f9ff;">
|
||||
<span class="step-num" style="background:#34a853;">R</span>
|
||||
<span class="title" style="color:#1a73e8;">📋 AI 生成的完整健康度报告</span>
|
||||
<span class="arrow">▶</span>
|
||||
</div>
|
||||
<div class="step-bd">
|
||||
<div class="report">
|
||||
<h4>项目健康度报告 — z2_cc/gitlink-cli</h4>
|
||||
<strong>报告日期:</strong>2026-06-17<br>
|
||||
<strong>综合评分:7.3/10 🟢 良好</strong><br><br>
|
||||
|
||||
<strong>一、Issue 状况(8/10 🟢)</strong><br>
|
||||
关闭率 87.5%,平均 2.7 天响应,积压基本稳定。<br><br>
|
||||
|
||||
<strong>二、PR 状况(8/10 🟢)</strong><br>
|
||||
合并率 80%,合并迅速。<br><br>
|
||||
|
||||
<strong>三、贡献者活跃度(6/10 🟡)</strong><br>
|
||||
单人维护,提交活跃,缺少新贡献者。<br><br>
|
||||
|
||||
<strong>四、里程碑进度(7/10 🟢)</strong><br>
|
||||
v1.3.0 完成 60%,按计划推进。<br><br>
|
||||
|
||||
<strong>五、改进建议</strong><br>
|
||||
<strong>🔴 高优先级</strong><br>
|
||||
1. 标记 good-first-issue 吸引新贡献者<br>
|
||||
2. 为 Issue 增加标签分类<br><br>
|
||||
<strong>🟡 中优先级</strong><br>
|
||||
3. 添加 CONTRIBUTING.md 引导新人<br>
|
||||
4. 定期清理积压 Issue<br><br>
|
||||
<strong>🟢 低优先级</strong><br>
|
||||
5. 考虑添加 CI/CD 自动化流水线
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== Release Notes ======================== -->
|
||||
<div class="panel" id="panel-release">
|
||||
<div class="card">
|
||||
<h3>Release Notes 生成</h3>
|
||||
<p>分析 commit 历史,按 Conventional Commits 规范分类统计,推荐语义化版本,生成结构化发布说明。</p>
|
||||
<div><span class="tag">release +list</span><span class="tag">git log</span><span class="tag">pr +list</span></div>
|
||||
<a class="file-link" href="../skills/gitlink-release-auto/SKILL.md" target="_blank">查看 SKILL.md →</a>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">获取基线版本</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="cmd"><span class="prompt">$</span> gitlink-cli release +list --owner z2_cc --repo gitlink-cli --format json</div>
|
||||
<div class="output">[ { "tag_name":"v1.2.0", "name":"v1.2.0", "body":"初始版本发布", "created_at":"2026-06-01" } ]</div>
|
||||
<div class="analysis"><strong>基线版本:</strong>v1.2.0(2026-06-01)<br>作为对比基准,计算自 v1.2.0 以来的所有变更。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">获取提交历史并分析</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="cmd"><span class="prompt">$</span> git log v1.2.0..HEAD --format="%H|%s|%an|%ad" --date=short</div>
|
||||
<div class="output">a4587f9|feat: add webhook +failed and +task-view commands|wqer|2026-06-04
|
||||
37a0f21|docs: add release notes workflow example|wqer|2026-06-17
|
||||
6c0ad3b|feat: add three new skills|wqer|2026-06-17
|
||||
d1cc68b|docs: add workflow examples|wqer|2026-06-17
|
||||
9a51db0|feat: add wiki and snippet skills|wqer|2026-06-17
|
||||
5866600|enhance: enrich skill outputs|wqer|2026-06-17
|
||||
21a3447|docs: update demo page|wqer|2026-06-17</div>
|
||||
|
||||
<div class="cmd"><span class="prompt">$</span> git log v1.2.0..HEAD --stat --oneline | tail -5</div>
|
||||
<div class="output">15 files changed, 980 insertions(+), 810 deletions(-)</div>
|
||||
|
||||
<div class="analysis">
|
||||
<strong>📊 AI 分类统计过程:</strong><br><br>
|
||||
<strong>Step 1 — 按 Conventional Commits 分类</strong><br>
|
||||
feat: add webhook +failed → ✨ 新功能<br>
|
||||
docs: add release notes → 📝 文档<br>
|
||||
feat: add three new skills → ✨ 新功能<br>
|
||||
docs: add workflow examples → 📝 文档<br>
|
||||
feat: add wiki and snippet → ✨ 新功能<br>
|
||||
enhance: enrich skill → ⚡ 优化<br>
|
||||
docs: update demo page → 📝 文档<br><br>
|
||||
|
||||
<strong>Step 2 — 数量统计</strong><br>
|
||||
✨ 新功能:3 次(43%)<br>
|
||||
📝 文档:3 次(43%)<br>
|
||||
⚡ 优化:1 次(14%)<br>
|
||||
<strong>总提交:7 次 | 贡献者:1 人</strong><br><br>
|
||||
|
||||
<strong>Step 3 — 变更规模</strong><br>
|
||||
涉及文件:15 个<br>
|
||||
新增行数:+980<br>
|
||||
删除行数:-810
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">版本号推荐</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>语义化版本推荐规则:</strong><br>
|
||||
<table>
|
||||
<tr><th>条件</th><th>版本变更</th></tr>
|
||||
<tr><td>包含 BREAKING CHANGE</td><td>主版本 +1(1.x.x → 2.0.0)</td></tr>
|
||||
<tr><td>包含 feat,无 breaking</td><td><strong>次版本 +1(1.2.x → 1.3.0) ✓</strong></td></tr>
|
||||
<tr><td>仅 fix/docs/chore</td><td>修订号 +1(1.2.0 → 1.2.1)</td></tr>
|
||||
</table>
|
||||
<br>
|
||||
<strong>当前版本:</strong>v1.2.0<br>
|
||||
<strong>包含 feat:</strong>是<br>
|
||||
<strong>包含 BREAKING CHANGE:</strong>否<br><br>
|
||||
<strong>推荐版本:v1.3.0 🏷</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)" style="background:#f0f9ff;">
|
||||
<span class="step-num" style="background:#34a853;">R</span>
|
||||
<span class="title" style="color:#1a73e8;">📋 AI 生成的完整 Release Notes</span>
|
||||
<span class="arrow">▶</span>
|
||||
</div>
|
||||
<div class="step-bd">
|
||||
<div class="report">
|
||||
<h4>v1.3.0 (2026-06-17)</h4>
|
||||
|
||||
<strong>📊 版本概览</strong><br>
|
||||
<table>
|
||||
<tr><td>提交次数</td><td>7 次</td></tr>
|
||||
<tr><td>贡献者</td><td>1 人</td></tr>
|
||||
<tr><td>涉及文件</td><td>15 个</td></tr>
|
||||
<tr><td>新增/删除</td><td>+980 / -810 行</td></tr>
|
||||
</table>
|
||||
|
||||
<strong>✨ 新功能(3)</strong><br>
|
||||
- feat: 新增 Webhook 投递监控(failed + task-view)<br>
|
||||
- feat: 新增三个 AI Agent Skill(issue-triage / project-health / newcomer-guide)<br>
|
||||
- feat: 新增 Wiki 和代码片段管理 Skill<br><br>
|
||||
|
||||
<strong>📝 文档(3)</strong><br>
|
||||
- docs: 添加 Release Notes 工作流示例<br>
|
||||
- docs: 添加其他工作流示例<br>
|
||||
- docs: 更新交互式演示页面<br><br>
|
||||
|
||||
<strong>⚡ 优化(1)</strong><br>
|
||||
- perf: 增强 Skill 输出的数据分析能力<br><br>
|
||||
|
||||
<strong>📈 统计汇总</strong><br>
|
||||
<table>
|
||||
<tr><th>类别</th><th>数量</th><th>占比</th><th>可视化</th></tr>
|
||||
<tr><td>✨ 新功能</td><td>3</td><td>43%</td><td><span class="bar" style="width:86px; background:#34a853;"></span></td></tr>
|
||||
<tr><td>📝 文档</td><td>3</td><td>43%</td><td><span class="bar" style="width:86px; background:#1a73e8;"></span></td></tr>
|
||||
<tr><td>⚡ 优化</td><td>1</td><td>14%</td><td><span class="bar" style="width:28px; background:#fbbc04;"></span></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== Issue 自动分拣 ======================== -->
|
||||
<div class="panel" id="panel-triage">
|
||||
<div class="card">
|
||||
<h3>Issue 自动分拣</h3>
|
||||
<p>通过关键词权重评分系统,对 Issue 自动分类、评估紧急度,并生成结构化引导评论。</p>
|
||||
<div><span class="tag">issue +list</span><span class="tag">issue +view</span><span class="tag">issue +comment</span></div>
|
||||
<a class="file-link" href="../skills/gitlink-issue-triage/SKILL.md" target="_blank">查看 SKILL.md →</a>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">采集待处理 Issue</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="cmd"><span class="prompt">$</span> gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json</div>
|
||||
<div class="output">[
|
||||
{"number":42, "subject":"登录页面报错:500 Internal Server Error",
|
||||
"body":"每次点击登录按钮后,页面白屏并返回 500 错误。已尝试清除缓存无效。"},
|
||||
{"number":43, "subject":"建议增加 CSV 导出功能",
|
||||
"body":"项目中需要将数据导出为 CSV 格式,希望增加此功能。"},
|
||||
{"number":44, "subject":"README 中缺少安装说明",
|
||||
"body":"第一次使用这个项目,发现 README 没有写如何安装和配置。"}
|
||||
]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">AI 关键词权重评分</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>🔍 Issue #42 — 登录页面报错:500 Internal Server Error</strong><br>
|
||||
┌────────────────────────────────────────────<br>
|
||||
│ 匹配关键词及权重:<br>
|
||||
│ 报错(5) + 错误(8) + 页面(0) + 白屏(3) = <strong>16 分</strong><br>
|
||||
│ 分类:🐛 Bug(≥15 分触发)<br>
|
||||
│ 紧急度:16 × 1.5 = <strong>24 → 🔴 P0 紧急</strong><br>
|
||||
└────────────────────────────────────────────<br><br>
|
||||
|
||||
<strong>🔍 Issue #43 — 建议增加 CSV 导出功能</strong><br>
|
||||
┌────────────────────────────────────────────<br>
|
||||
│ 匹配关键词及权重:<br>
|
||||
│ 建议(8) + 增加(5) + 功能(3) + 导出(3) = <strong>19 分</strong><br>
|
||||
│ 分类:✨ 功能需求(≥12 分触发)<br>
|
||||
│ 紧急度:19 × 1.0 = <strong>19 → 🟡 P1 高优先</strong><br>
|
||||
└────────────────────────────────────────────<br><br>
|
||||
|
||||
<strong>🔍 Issue #44 — README 缺少安装说明</strong><br>
|
||||
┌────────────────────────────────────────────<br>
|
||||
│ 匹配关键词及权重:<br>
|
||||
│ 文档(10) + 缺少(4) + 安装(3) + README(8) = <strong>25 分</strong><br>
|
||||
│ 分类:📝 文档(≥10 分触发)<br>
|
||||
│ 紧急度:25 × 1.0 = <strong>25 → 🟡 P1 高优先</strong><br>
|
||||
└────────────────────────────────────────────
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">AI 生成引导评论</span><span class="arrow">▶</span></div>
|
||||
<div class="step-bd">
|
||||
<div class="analysis">
|
||||
<strong>🤖 AI 为 Issue #42(Bug / P0 紧急)生成的评论:</strong>
|
||||
</div>
|
||||
<div class="report">
|
||||
<strong>Issue 自动分类结果</strong><br><br>
|
||||
<strong>类别:</strong>🐛 Bug<br>
|
||||
<strong>紧急度:</strong>🔴 P0 紧急 — 建议立即处理<br>
|
||||
<strong>建议标签:</strong>bug<br><br>
|
||||
<strong>分类依据:</strong><br>
|
||||
标题和描述中包含关键词「报错」「错误」「白屏」,匹配 Bug 分类(权重 16 分)。<br><br>
|
||||
<strong>请补充以下信息以便排查:</strong><br>
|
||||
1. 运行环境:操作系统 / 浏览器版本<br>
|
||||
2. 复现步骤:详细描述如何触发此 bug<br>
|
||||
3. 错误日志:浏览器控制台是否有报错信息<br>
|
||||
4. 是否必现:每次操作都能复现吗?<br><br>
|
||||
<strong>自动分配:</strong>已通知项目维护者。
|
||||
</div>
|
||||
|
||||
<div class="analysis" style="margin-top:12px;">
|
||||
<strong>🤖 AI 为 Issue #43(功能需求 / P1 高优先)生成的评论:</strong>
|
||||
</div>
|
||||
<div class="report">
|
||||
<strong>Issue 自动分类结果</strong><br><br>
|
||||
<strong>类别:</strong>✨ 功能需求<br>
|
||||
<strong>紧急度:</strong>🟡 P1 高优先<br>
|
||||
<strong>建议标签:</strong>enhancement<br><br>
|
||||
<strong>分类依据:</strong><br>
|
||||
标题中包含关键词「建议」「增加」,匹配功能需求分类(权重 19 分)。<br><br>
|
||||
<strong>请补充以下信息:</strong><br>
|
||||
1. 这个功能解决了什么场景的问题?<br>
|
||||
2. 期望的导出格式是什么?<br>
|
||||
3. 是否有参考实现?<br><br>
|
||||
感谢你的建议!项目组会评估这个需求的可行性。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggle(el) {
|
||||
el.classList.toggle('open');
|
||||
el.nextElementSibling.classList.toggle('open');
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(function(t) {
|
||||
t.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});
|
||||
document.querySelectorAll('.panel').forEach(function(x){x.classList.remove('active')});
|
||||
this.classList.add('active');
|
||||
document.getElementById('panel-' + this.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Shortcut 命令实现报告
|
||||
|
||||
## 总体设计模式
|
||||
|
||||
三个 Shortcut 组(`wiki`、`snippet`、`hook-runner`)虽然功能不同,但整体架构遵循一致的设计模式:
|
||||
|
||||
1. **命令注册**:每个 Shortcut 组通过 `Shortcuts()` 函数返回 `[]*common.Shortcut`,在 `shortcuts/register.go` 中统一挂载到 cobra 根命令
|
||||
2. **上下文注入**:通过 `RuntimeContext` 提供 API 客户端、owner/repo 解析、输出格式化等基础设施
|
||||
3. **owner/repo 解析**:每个命令先调用 `ctx.ResolveOwnerRepo()` 从 flags 或 git remote 自动解析
|
||||
4. **数据访问**:读操作用公开方式(无需认证),写操作用 Token 认证
|
||||
5. **输出格式**:统一支持 `json` / `table` 格式,通过 `ctx.Output()` / `ctx.OutputData()` 输出
|
||||
6. **错误处理**:参数校验置前(必填 flag 检查),API 错误直接传播
|
||||
|
||||
> **注意**:以下所有演示命令假设在 `gitlink-cli` 仓库目录下运行。`--owner` 和 `--repo` 参数会自动从 Git remote 解析,无需手动传入。
|
||||
|
||||
---
|
||||
|
||||
## 1. Wiki 管理(`wiki`)
|
||||
|
||||
### 实现思路
|
||||
|
||||
GitLink 的 Wiki 本质是一个**独立的 Git 仓库**(`https://gitlink.org.cn/{owner}/{repo}.wiki.git`),每个页面是一个 `.md` 文件,侧边栏由 `_Sidebar.md` 控制。
|
||||
|
||||
| 操作 | 方式 | 认证 |
|
||||
|------|------|------|
|
||||
| 读(list/view) | `git clone` 到临时目录,读取文件 | ❌ 公开仓库,无需认证 |
|
||||
| 写(create/update/delete) | `git clone` → 修改文件 → `git commit/push` | ✅ Personal Access Token |
|
||||
|
||||
删除时额外删除 `_Sidebar.md`,让 GitLink 网页端自动从文件列表重建侧边栏,防止残留引用。
|
||||
|
||||
### 遇到的问题
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| 修改 `_Sidebar.md` 内容导致网页渲染崩溃 | `_Sidebar.md` 的编码/格式与 GitLink 网页端的预期不一致 | 不修改内容,直接删除文件,让 GitLink 自动重建 |
|
||||
| `git add _Sidebar.md` 文件不存在时报错 | `_Sidebar.md` 尚未被网页端生成时不存在 | 用 `git add -A` 替代,自动识别所有变更 |
|
||||
|
||||
### 演示指令
|
||||
|
||||
```powershell
|
||||
# 列出 Wiki 页面(Git 克隆 → 读取目录 → 输出)
|
||||
./gitlink-cli.exe wiki +list --format table
|
||||
|
||||
# 查看 Wiki 页面内容(Git 克隆 → 读取文件内容 + Git log)
|
||||
./gitlink-cli.exe wiki +view --page-name "Wiki测试" --format json
|
||||
|
||||
# 创建 Wiki 页面(Git 克隆 → 写 .md 文件 → git add/commit/push)
|
||||
./gitlink-cli.exe wiki +create --page-name "demo" --title "Demo" --content "Hello World" --message "创建Wiki页面"
|
||||
|
||||
# 更新 Wiki 页面(Git 克隆 → 修改文件 → git add/commit/push)
|
||||
./gitlink-cli.exe wiki +update --page-name "demo" --title "Updated" --content "New content"
|
||||
|
||||
# 删除 Wiki 页面(Git 克隆 → 删文件+删_Sidebar.md → git add -A/commit/push)
|
||||
./gitlink-cli.exe wiki +delete --page-name "demo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 代码片段(`snippet`)
|
||||
|
||||
### 实现思路
|
||||
|
||||
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个文件,读操作用 Git 克隆,写操作用文件 API。
|
||||
|
||||
| 操作 | 方式 | 说明 |
|
||||
|------|------|------|
|
||||
| 列出片段 | `git clone` → 读取 `snippets/` 目录 | 读操作无需认证 |
|
||||
| 查看片段 | `git clone` → 读取指定文件内容 | 读操作无需认证 |
|
||||
| 创建片段 | `POST /{owner}/{repo}/create_file` API | 写操作需 Token |
|
||||
| 删除片段 | `git hash-object` 获取 SHA → `DELETE /delete_file` API | 先获取文件 SHA 再删除 |
|
||||
|
||||
创建时自动用代码块语法包裹内容(支持 `--language` 指定语言)。
|
||||
|
||||
### 演示指令
|
||||
|
||||
```powershell
|
||||
# 创建代码片段(POST 文件 API → 写入 snippets/ 目录)
|
||||
./gitlink-cli.exe snippet +create --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
|
||||
|
||||
# 列出代码片段(Git 克隆 → 读取 snippets/ 目录 → 输出)
|
||||
./gitlink-cli.exe snippet +list --format table
|
||||
|
||||
# 查看代码片段(Git 克隆 → 读取指定文件内容 → 输出)
|
||||
./gitlink-cli.exe snippet +view --name "hello.py"
|
||||
|
||||
# 删除代码片段(Git 获取 SHA → DELETE 文件 API)
|
||||
./gitlink-cli.exe snippet +delete --name "hello.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Webhook 投递监控(`webhook`)
|
||||
|
||||
> 以下功能已合并到 `webhook` 命令组中,`hook-runner` 已删除。
|
||||
|
||||
### 实现思路
|
||||
|
||||
Webhook 每次触发事件(push、Issue 操作、PR 操作等)时,GitLink 会向配置的 URL 发送 HTTP 请求,这个过程称为"投递"。通过 Webhook 历史推送列表 API 获取投递记录。
|
||||
|
||||
| 命令 | API 路径 | 说明 |
|
||||
|------|---------|------|
|
||||
| `webhook +tasks` | `GET /v1/{owner}/{repo}/webhooks/{id}/hooktasks` | 查看投递历史(原有命令,保持不变) |
|
||||
| `webhook +failed` | 从投递列表中过滤失败项 | 仅查看投递失败的记录 |
|
||||
| `webhook +task-view` | 从投递列表中按 ID 筛选 | 查看某次投递的请求体、响应状态等 |
|
||||
|
||||
### 演示指令
|
||||
|
||||
```powershell
|
||||
# 查看投递历史(原有命令不变)
|
||||
./gitlink-cli.exe webhook +tasks --id 51102
|
||||
|
||||
# 查看投递失败的记录
|
||||
./gitlink-cli.exe webhook +failed --id 51102
|
||||
|
||||
# 查看某次投递的详细内容
|
||||
./gitlink-cli.exe webhook +task-view --id 51102 --task-id 4775684
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
# 运行全部 Shortcut 测试
|
||||
cd E:/gitlink-cli/gitlink-cli
|
||||
go test ./shortcuts/... -count=1
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,363 @@
|
|||
# gitlink-cli API 全量封装 + 跨平台兼容性设计
|
||||
|
||||
**日期:** 2026-06-01
|
||||
**状态:** 已批准
|
||||
**范围:** 补全 Raw API 封装(对齐 GitLink OpenAPI 约 200 个端点)、提升跨平台兼容性和安装体验
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
gitlink-cli 当前封装了约 90 个 GitLink OpenAPI 端点(18 个快捷方式域),但 API 参考文档记录了约 200 个端点。同时,跨平台体验存在改进空间:终端输出在 Windows 上可能渲染不佳、npm 安装缺少 checksum 验证、缺少常见包管理器支持。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 补全所有 GitLink OpenAPI 端点的快捷方式封装
|
||||
2. 提升跨平台兼容性(终端输出、路径处理、字符宽度)
|
||||
3. 改善安装体验(npm 安装健壮性、包管理器分发、CI 改进)
|
||||
|
||||
## 实现策略
|
||||
|
||||
采用**分层渐进式**方案,按 L1→L2→L3→L4 四层推进,每层在功能分支上逐步提交。
|
||||
|
||||
---
|
||||
|
||||
## L1:补全已有域的缺失操作(~35 个端点)
|
||||
|
||||
### 1.1 Issue 评论 CRUD 补全
|
||||
|
||||
文件:`shortcuts/issue/issue.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `issue +comments` | GET | `/v1/{owner}/{repo}/issues/{index}/journals` | 评论/活动列表 |
|
||||
| `issue +update-comment` | PATCH | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 更新评论 |
|
||||
| `issue +delete-comment` | DELETE | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 删除评论 |
|
||||
| `issue +reply-comment` | GET | `/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals` | 子评论列表 |
|
||||
| `issue +delete` | DELETE | `/v1/{owner}/{repo}/issues/{index}` | 删除 issue |
|
||||
| `issue +batch-update` | PATCH | `/v1/{owner}/{repo}/issues/batch_update` | 批量更新 |
|
||||
| `issue +batch-destroy` | DELETE | `/v1/{owner}/{repo}/issues/batch_destroy` | 批量删除 |
|
||||
|
||||
### 1.2 PR 评论 CRUD 补全
|
||||
|
||||
文件:`shortcuts/pr/pr.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `pr +comments` | GET | `/v1/{owner}/{repo}/pulls/{index}/journals` | 审查评论列表 |
|
||||
| `pr +create-comment` | POST | `/v1/{owner}/{repo}/pulls/{index}/journals` | 创建审查评论 |
|
||||
| `pr +update-comment` | PUT | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 更新审查评论 |
|
||||
| `pr +delete-comment` | DELETE | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 删除审查评论 |
|
||||
| `pr +commits` | GET | `/{owner}/{repo}/pulls/{id}/commits` | PR 提交列表 |
|
||||
| `pr +reopen` | POST | `/v1/{owner}/{repo}/pulls/{index}/reopen` | 重新打开 PR |
|
||||
| `pr +update` | PUT | `/{owner}/{repo}/pulls/{index}` | 更新 PR |
|
||||
|
||||
### 1.3 分支补全
|
||||
|
||||
文件:`shortcuts/branch/branch.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `branch +all` | GET | `/v1/{owner}/{repo}/branches/all` | 所有分支(无分页) |
|
||||
| `branch +default` | PATCH | `/v1/{owner}/{repo}/branches/update_default_branch` | 更新默认分支 |
|
||||
| `branch +restore` | POST | `/v1/{owner}/{repo}/branches/restore` | 恢复已删除分支 |
|
||||
|
||||
### 1.4 Release 补全
|
||||
|
||||
文件:`shortcuts/release/release.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `release +edit` | GET | `/{owner}/{repo}/releases/{id}/edit` | 编辑元数据 |
|
||||
| `release +update` | PUT | `/{owner}/{repo}/releases/{id}` | 更新 release |
|
||||
|
||||
### 1.5 项目补全
|
||||
|
||||
文件:`shortcuts/repo/repo.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `repo +update` | PATCH | `/{owner}/{repo}` | 更新项目设置 |
|
||||
| `repo +about` | GET | `/{owner}/{repo}/about` | 项目主页/关于 |
|
||||
| `repo +menu` | GET | `/{owner}/{repo}/menu_list` | 项目导航列表 |
|
||||
| `repo +units-get` | GET | `/{owner}/{repo}/project_units` | 导航设置 |
|
||||
| `repo +units-set` | POST | `/{owner}/{repo}/project_units` | 更新导航设置 |
|
||||
| `repo +code-stats` | GET | `/v1/{owner}/{repo}/code_stats` | 代码统计 |
|
||||
| `repo +languages` | GET | `/{owner}/{repo}/languages` | 项目语言 |
|
||||
| `repo +contributors` | GET | `/{owner}/{repo}/contributors` | 贡献者列表 |
|
||||
| `repo +contributors-stat` | GET | `/v1/{owner}/{repo}/contributors/stat` | 贡献者代码统计 |
|
||||
| `repo +recommend` | GET | `/projects/recommend` | 推荐项目 |
|
||||
| `repo +star` | POST | `/projects/{id}/praise_tread/like` | 点赞 |
|
||||
| `repo +unstar` | DELETE | `/projects/{id}/praise_tread/unlike` | 取消点赞 |
|
||||
| `repo +watch` | POST | `/watchers/follow` | 关注 |
|
||||
| `repo +unwatch` | DELETE | `/watchers/unfollow` | 取消关注 |
|
||||
| `repo +stargazers` | GET | `/{owner}/{repo}/stargazers` | 点赞者列表 |
|
||||
| `repo +watchers` | GET | `/{owner}/{repo}/watchers` | 关注者列表 |
|
||||
| `repo +transfer` | POST | `/{owner}/{repo}/applied_transfer_projects` | 传输项目 |
|
||||
| `repo +cancel-transfer` | POST | `/{owner}/{repo}/applied_transfer_projects/cancel` | 取消传输 |
|
||||
| `repo +transfer-orgs` | GET | `/{owner}/{repo}/applied_transfer_projects/organizations` | 可传输的组织 |
|
||||
| `repo +invite-link` | GET | `/{owner}/{repo}/project_invite_links/current_link` | 邀请链接 |
|
||||
| `repo +invite-info` | GET | `/{owner}/{repo}/project_invite_links/show_link` | 邀请链接信息 |
|
||||
| `repo +edit-detail` | GET | `/{owner}/{repo}/edit` | 编辑详情 |
|
||||
| `repo +simple` | GET | `/{owner}/{repo}/simple` | 简化详情 |
|
||||
|
||||
### 1.6 用户消息与统计
|
||||
|
||||
文件:`shortcuts/user/user.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `user +update` | PUT | `/users/{owner}` | 更新用户信息 |
|
||||
| `user +messages` | GET | `/users/{owner}/messages` | 消息列表 |
|
||||
| `user +delete-message` | DELETE | `/users/{owner}/messages` | 删除消息 |
|
||||
| `user +create-message` | POST | `/users/{owner}/messages` | 创建消息 |
|
||||
| `user +read-message` | POST | `/users/{owner}/messages/read` | 标记已读 |
|
||||
| `user +msg-settings` | GET | `/users/{owner}/template_message_settings` | 消息设置 |
|
||||
| `user +update-msg-settings` | POST | `/users/{owner}/template_message_settings/update_setting` | 更新消息设置 |
|
||||
| `user +pinned-projects` | GET | `/users/{owner}/is_pinned_projects` | 置顶项目 |
|
||||
| `user +pin-project` | POST | `/users/{owner}/is_pinned_projects/pin` | 置顶 |
|
||||
| `user +reorder-pinned` | PUT | `/users/{owner}/is_pinned_projects/{id}` | 重排置顶 |
|
||||
| `user +activity` | GET | `/users/{owner}/statistics/activity` | 活动统计 |
|
||||
| `user +headmap` | GET | `/users/{owner}/headmaps` | 贡献热力图 |
|
||||
| `user +develop-stats` | GET | `/users/{owner}/statistics/develop` | 开发能力 |
|
||||
| `user +role-stats` | GET | `/users/{owner}/statistics/role` | 角色定位 |
|
||||
| `user +major-stats` | GET | `/users/{owner}/statistics/major` | 专业定位 |
|
||||
| `user +applied-transfers` | GET | `/users/{owner}/applied_transfer_projects` | 待处理传输 |
|
||||
| `user +accept-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/accept` | 接受传输 |
|
||||
| `user +refuse-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/refuse` | 拒绝传输 |
|
||||
| `user +applied-projects` | GET | `/users/{owner}/applied_projects` | 待处理加入申请 |
|
||||
| `user +accept-join` | POST | `/users/{owner}/applied_projects/{id}/accept` | 接受加入 |
|
||||
| `user +refuse-join` | POST | `/users/{owner}/applied_projects/{id}/refuse` | 拒绝加入 |
|
||||
| `user +feedback` | POST | `/v1/{owner}/feedbacks` | 反馈建议 |
|
||||
|
||||
### 1.7 组织补全
|
||||
|
||||
文件:`shortcuts/org/org.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `org +add-team-projects` | POST | `/organizations/{org}/teams/{id}/team_projects/create_all` | 添加所有项目到团队 |
|
||||
| `org +remove-team-projects` | DELETE | `/organizations/{org}/teams/{id}/team_projects/destroy_all` | 从团队删除所有项目 |
|
||||
|
||||
### 1.8 文件操作补全
|
||||
|
||||
文件:`shortcuts/file/file.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `file +batch` | POST | `/v1/{owner}/{repo}/contents/batch` | 批量提交文件 |
|
||||
| `file +entries` | GET | `/{owner}/{repo}/entries` | 目录列表 |
|
||||
| `file +sub-entries` | GET | `/{owner}/{repo}/sub_entries` | 子目录/文件 |
|
||||
| `file +replace` | POST | `/{owner}/{repo}/replace_file` | 替换文件 |
|
||||
|
||||
### 1.9 辅助工具
|
||||
|
||||
文件:`shortcuts/util/util.go`(新建)
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `util +licenses` | GET | `/licenses` | 许可证模板列表 |
|
||||
| `util +ignores` | GET | `/ignores` | 忽略文件模板列表 |
|
||||
| `util +platform-msg-settings` | GET | `/template_message_settings` | 平台消息设置 |
|
||||
|
||||
---
|
||||
|
||||
## L2:新增领域快捷方式(~19 个端点)
|
||||
|
||||
### 2.1 数据集
|
||||
|
||||
新建文件:`shortcuts/dataset/dataset.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `dataset +create` | POST | `/v1/{owner}/{repo}/dataset` | 创建数据集 |
|
||||
| `dataset +update` | PUT | `/v1/{owner}/{repo}/dataset` | 更新数据集 |
|
||||
| `dataset +view` | GET | `/v1/{owner}/{repo}/dataset` | 数据集详情 + 附件 |
|
||||
| `dataset +list` | GET | `/v1/project_datasets` | 全局数据集列表 |
|
||||
|
||||
### 2.2 模板
|
||||
|
||||
新建文件:`shortcuts/template/template.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `template +list` | GET | `/v1/{owner}/project_templates` | 模板列表 |
|
||||
| `template +create` | POST | `/v1/{owner}/project_templates` | 创建模板 |
|
||||
| `template +view` | GET | `/v1/{owner}/{repo}/project_templates/{id}` | 模板详情 |
|
||||
| `template +update` | PUT | `/v1/{owner}/project_templates/{id}` | 更新模板 |
|
||||
| `template +delete` | DELETE | `/v1/{owner}/project_templates/{id}` | 删除模板 |
|
||||
|
||||
### 2.3 CI 流水线补全
|
||||
|
||||
文件:`shortcuts/ci/ci.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `ci +pipelines` | GET | `/pm/pipelines` | 流水线模板列表 |
|
||||
| `ci +run-pipeline` | POST | `/v1/{owner}/{repo}/actions/runs` | 运行流水线 |
|
||||
| `ci +runs` | GET | `/v1/{owner}/{repo}/actions/runs` | 运行历史 |
|
||||
| `ci +save-yaml` | POST | `/v1/{owner}/{repo}/pipelines/save_yaml` | 保存 YAML |
|
||||
| `ci +pipeline-detail` | GET | `/v1/{owner}/{repo}/pipelines/{id}` | 流水线详情 |
|
||||
| `ci +delete-pipeline` | DELETE | `/v1/{owner}/{repo}/pipelines/{id}` | 删除流水线 |
|
||||
| `ci +disable` | POST | `/v1/{owner}/{repo}/actions/disable` | 禁用流水线 |
|
||||
| `ci +enable` | POST | `/v1/{owner}/{repo}/actions/enable` | 启用流水线 |
|
||||
| `ci +run-log` | POST | `/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | 运行日志 |
|
||||
| `ci +run-results` | GET | `/v1/{owner}/{repo}/pipelines/run_results` | 运行报告 |
|
||||
|
||||
### 2.4 Wiki 导入/导出
|
||||
|
||||
文件:`shortcuts/wiki/wiki.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `wiki +export` | GET | `/wikiExport/wikiExport-wrapper` | 导出 Wiki |
|
||||
| `wiki +import` | POST | `/wikiExport/uploadWiki/{owner}/{repoName}/{projectId}` | 导入 Wiki |
|
||||
|
||||
### 2.5 附件
|
||||
|
||||
新建文件:`shortcuts/attachment/attachment.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `attachment +upload` | POST | `/attachments` | 上传文件 |
|
||||
| `attachment +delete` | DELETE | `/attachments/{uuid}` | 删除附件 |
|
||||
|
||||
---
|
||||
|
||||
## L3:用户/平台管理(~30 个端点)
|
||||
|
||||
### 3.1 用户个人设置
|
||||
|
||||
文件:`shortcuts/user/user.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `user +change-password` | POST | `/accounts/change_password` | 修改密码 |
|
||||
| `user +update-email` | PATCH | `/v1/{owner}/update_email` | 更改邮箱 |
|
||||
| `user +update-phone` | PATCH | `/v1/{owner}/update_phone` | 更改手机 |
|
||||
| `user +send-verify` | GET | `/v1/{owner}/send_email_vefify_code` | 发送验证码 |
|
||||
| `user +check-verify` | POST | `/v1/{owner}/check_email_verify_code` | 验证验证码 |
|
||||
| `user +check-password` | POST | `/v1/{owner}/check_password` | 验证密码 |
|
||||
| `user +check-email` | POST | `/v1/{owner}/check_email` | 验证邮箱 |
|
||||
| `user +can-delete` | POST | `/v1/{owner}/check_user_can_delete` | 检查可否删除 |
|
||||
| `user +delete` | DELETE | `/v1/{owner}` | 停用用户 |
|
||||
|
||||
### 3.2 OAuth2 认证
|
||||
|
||||
文件:`cmd/auth/auth.go`、`internal/auth/login.go`
|
||||
|
||||
| 功能 | 方法 | 端点 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `auth login --oauth` | POST | `/oauth/token` (password grant) | OAuth2 密码模式登录 |
|
||||
| `auth refresh` | POST | `/oauth/token` (refresh_token grant) | 刷新令牌 |
|
||||
|
||||
认证流程:`--oauth` 标志触发 OAuth2 路径而非 cookie 路径。获取到的 access_token 存储到 keyring,后续请求使用 token 认证。
|
||||
|
||||
### 3.3 项目管理增强
|
||||
|
||||
文件:`shortcuts/repo/repo.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `repo +join` | POST | `/applied_projects` | 申请加入项目 |
|
||||
| `repo +quit` | POST | `/{owner}/{repo}/quit` | 退出项目 |
|
||||
| `repo +migrate` | POST | `/projects/migrate` | 创建镜像项目 |
|
||||
| `repo +sync-mirror` | POST | `/repositories/{id}/sync_mirror` | 同步镜像 |
|
||||
| `repo +topics` | GET | `/v1/project_topics` | 项目主题列表 |
|
||||
| `repo +create-topic` | POST | `/v1/project_topics` | 创建主题 |
|
||||
| `repo +delete-topic` | DELETE | `/v1/project_topics/{id}` | 删除主题 |
|
||||
|
||||
---
|
||||
|
||||
## L4:跨平台兼容性和安装体验
|
||||
|
||||
### 4.1 终端输出兼容性
|
||||
|
||||
**改动文件:** `internal/output/formatter.go`、`cmd/root.go`
|
||||
|
||||
**颜色处理:**
|
||||
- 添加 `--color` / `--no-color` 全局标志
|
||||
- 自动检测终端颜色支持:检查 `NO_COLOR` 环境变量、`COLORTERM`、Windows Terminal (`WT_SESSION`)、`ConEmuANSI`
|
||||
- 不支持颜色时自动降级为纯文本输出
|
||||
|
||||
**表格渲染:**
|
||||
- 使用 `golang.org/x/term`(已有依赖)获取终端宽度
|
||||
- 列过多时自动截断长值,保证不换行
|
||||
- 中文字符宽度计算:检测 rune 是否在 CJK 范围 `一-鿿`,是则宽度为 2
|
||||
|
||||
### 4.2 npm 安装体验
|
||||
|
||||
**改动文件:** `npm/scripts/install.js`
|
||||
|
||||
- 下载后验证 SHA256 checksum(从 GitHub Release 的 checksums.txt 获取)
|
||||
- 更好的错误分类和消息:
|
||||
- `ENOTFOUND` → "DNS 解析失败,请检查网络连接"
|
||||
- `ECONNREFUSED` → "连接被拒绝,请检查代理设置"
|
||||
- `ETIMEDOUT` → "连接超时,请尝试设置 HTTP_PROXY"
|
||||
- `EACCES` → "权限不足,请使用管理员权限运行或检查安装目录"
|
||||
- 离线模式:检查 `npm_config_cache` 中是否已有匹配版本
|
||||
- `--verbose` 日志支持
|
||||
|
||||
**改动文件:** `npm/bin/cli.js`
|
||||
|
||||
- 启动时检查新版本(可配置关闭),提示 "New version X.Y.Z available"
|
||||
- 二进制不存在时输出安装指引而非报错
|
||||
|
||||
### 4.3 包管理器分发
|
||||
|
||||
新增以下配置文件:
|
||||
|
||||
| 平台 | 文件路径 | 说明 |
|
||||
|------|----------|------|
|
||||
| Homebrew | `formula/gitlink-cli.rb` | Ruby formula,从 GitHub Release 下载 |
|
||||
| Scoop | `bucket/gitlink-cli.json` | JSON manifest |
|
||||
| Chocolatey | `choco/gitlink-cli.nuspec` + `choco/tools/chocolateyinstall.ps1` | NuGet 包 |
|
||||
|
||||
每个配置文件中的版本号和 SHA256 由 release workflow 自动更新。
|
||||
|
||||
### 4.4 GitHub Actions CI 改进
|
||||
|
||||
**新增:** `.github/workflows/ci.yml`
|
||||
- 触发:PR 和 push 到 master
|
||||
- 步骤:`go test ./...`、`go vet`、`golangci-lint run`
|
||||
|
||||
**改进:** `.github/workflows/release.yml`
|
||||
- 添加 SHA256 checksum 文件上传到 Release
|
||||
- 自动更新 Homebrew formula / Scoop manifest / Chocolatey nuspec 中的版本和 hash
|
||||
|
||||
### 4.5 构建优化
|
||||
|
||||
**改动文件:** `Makefile`
|
||||
|
||||
- 确保 `CGO_ENABLED=0`(已有)
|
||||
- 添加 `-ldflags "-s -w"` 减小二进制体积(已有 `-s -w` 在部分目标)
|
||||
- 统一所有构建目标使用相同的 ldflags
|
||||
|
||||
---
|
||||
|
||||
## 架构约束
|
||||
|
||||
1. **所有新快捷方式遵循现有声明式框架**:使用 `common.Shortcut`、`common.Flag`、`common.RuntimeContext`
|
||||
2. **不引入新的 Go 依赖**(颜色检测、终端宽度、CJK 宽度均用标准库或已有依赖实现)
|
||||
3. **每个新快捷方式组注册到 `shortcuts/register.go`**
|
||||
4. **测试覆盖**:每个新快捷方式的非平凡逻辑都需要测试(使用 `httptest` 模拟服务器)
|
||||
5. **错误处理**:遵循现有 `client.go` 的错误模式(检查 HTTP 200 + `status` 字段)
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 不修改现有快捷方式的行为(只添加新的)
|
||||
- 不添加交互式向导模式
|
||||
- 不实现图形界面
|
||||
- 不添加国际化(保持中文/英文双语 README)
|
||||
|
||||
---
|
||||
|
||||
## 预估工作量
|
||||
|
||||
| 层 | 新增快捷方式数 | 新增文件 | 预估工作量 |
|
||||
|-----|--------------|---------|-----------|
|
||||
| L1 | ~70 | 1 (`shortcuts/util/`) | 大 |
|
||||
| L2 | ~19 | 3 (`dataset/`, `template/`, `attachment/`) | 中 |
|
||||
| L3 | ~16 | 0 | 中 |
|
||||
| L4 | N/A | ~5 (包管理器配置 + CI) | 中 |
|
||||
| **合计** | ~105 个操作 | ~9 个新文件/目录 | — |
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
# gitlink-cli 交互式 REPL 命令面板设计
|
||||
|
||||
## 概述
|
||||
|
||||
为 gitlink-cli 添加交互式 REPL 模式。用户运行 `gitlink` 或 `gitlink interactive` 进入持续会话,输入 `/` 弹出两级分组的命令面板,支持模糊搜索和方向键选择;选中命令后弹出表单式参数填充界面;提交后内联显示执行结果。
|
||||
|
||||
## 需求总结
|
||||
|
||||
| 需求 | 决策 |
|
||||
|---|---|
|
||||
| 进入方式 | REPL 模式 — `gitlink interactive` 或 `gitlink i` 进入持续会话 |
|
||||
| 命令面板触发 | 输入 `/` 弹出命令面板,输入 `/xxx` 直接进入模糊搜索 |
|
||||
| 命令分组 | 两级分组:先选命令组(issue/pr/repo...),再选子命令(+list/+create...) |
|
||||
| 参数填充 | 表单式 — Tab 切换字段,必填参数 `*` 标记,校验不通过红色提示 |
|
||||
| 结果展示 | 内联显示在 REPL 中,执行完回到输入提示符 |
|
||||
| 退出 | 输入 `exit`/`quit` 或 Ctrl+D |
|
||||
|
||||
## 技术选型
|
||||
|
||||
**Charm 生态(bubbletea + bubbles + huh)**
|
||||
|
||||
| 库 | 版本约束 | 职责 |
|
||||
|---|---|---|
|
||||
| `github.com/charmbracelet/bubbletea` | latest | TUI 框架,Elm 架构状态机 |
|
||||
| `github.com/charmbracelet/bubbles` | latest | 列表、输入框、搜索过滤等内置组件 |
|
||||
| `github.com/charmbracelet/huh` | latest | 表单系统:字段校验、Tab 切换、分组表单 |
|
||||
|
||||
选择理由:Go TUI 事实标准(bubbletea 27k+ stars),三个库同属 Charm 生态天然集成,huh 原生支持表单校验,Windows Terminal 兼容性好。
|
||||
|
||||
## 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ REPL 主循环 │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ gitlink (owner/repo)> █ │ │
|
||||
│ │ │ │
|
||||
│ │ 输入文本 → 判断是否以 / 开头 │ │
|
||||
│ │ ├── / → 进入命令面板(Level 1: 命令组) │ │
|
||||
│ │ ├── /xx → 进入命令面板(模糊搜索) │ │
|
||||
│ │ ├── 直接输入 → 当作快捷命令执行 │ │
|
||||
│ │ └── exit/quit → 退出 REPL │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────── 命令面板 ──────┐ ┌─────── 表单 ──────┐│
|
||||
│ │ 两级分组 + 模糊搜索 │ │ 参数填充 + 校验 ││
|
||||
│ │ bubbles/list 组件 │ │ huh Form 组件 ││
|
||||
│ └────────────────────────┘ └───────────────────┘│
|
||||
│ │
|
||||
│ ┌─────── 命令执行 ──────┐ │
|
||||
│ │ 复用现有 Shortcut.Run │ │
|
||||
│ │ 结果内联显示在 REPL 中 │ │
|
||||
│ └────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心原则**:纯增量新增,不修改现有命令逻辑。仅对 `cmd/root.go` 新增 1 行注册,对 `shortcuts/register.go` 新增 1 个导出函数。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
gitlink-cl/
|
||||
├── cmd/
|
||||
│ ├── interactive/ ← 新增包
|
||||
│ │ ├── interactive.go ← Cobra 命令注册 + 启动 bubbletea
|
||||
│ │ ├── repl.go ← REPL 主循环 (bubbletea Model)
|
||||
│ │ ├── palette.go ← 命令面板 (两级分组 + 模糊搜索)
|
||||
│ │ ├── form.go ← 参数表单 (huh Form 包装)
|
||||
│ │ └── executor.go ← 命令执行器 (调用 Shortcut.Run)
|
||||
│ ├── root.go ← 新增 1 行: AddCommand(interactive.NewInteractiveCmd())
|
||||
│ └── ...
|
||||
├── shortcuts/
|
||||
│ ├── register.go ← 新增导出函数: GetAllShortcuts()
|
||||
│ └── ...
|
||||
└── go.mod ← 新增 bubbletea/bubbles/huh 依赖
|
||||
```
|
||||
|
||||
## 各模块详细设计
|
||||
|
||||
### 1. interactive.go — 入口
|
||||
|
||||
注册为 cobra 子命令:
|
||||
|
||||
```
|
||||
gitlink interactive # 完整命令
|
||||
gitlink i # 短别名
|
||||
```
|
||||
|
||||
启动时:
|
||||
1. 解析全局 flags(owner、repo、format、debug)
|
||||
2. 调用 `shortcuts.GetAllShortcuts()` 获取命令元数据
|
||||
3. 创建 bubbletea Program 并运行 REPL Model
|
||||
|
||||
### 2. repl.go — REPL 主循环
|
||||
|
||||
基于 bubbletea 的 Elm 架构(Model-View-Update)。
|
||||
|
||||
**状态定义**:
|
||||
|
||||
```go
|
||||
type replState int
|
||||
|
||||
const (
|
||||
stateInput replState = iota // 等待用户输入
|
||||
statePalette // 命令面板
|
||||
stateForm // 参数表单
|
||||
stateExecuting // 命令执行中
|
||||
)
|
||||
|
||||
type replModel struct {
|
||||
state replState
|
||||
input textinput.Model // 底部输入框
|
||||
palette paletteModel // 命令面板子组件
|
||||
form formModel // 参数表单子组件
|
||||
executor *Executor // 命令执行器
|
||||
shortcuts map[string][]*common.Shortcut // 命令元数据
|
||||
output string // 上一次命令输出
|
||||
width int
|
||||
height int
|
||||
}
|
||||
```
|
||||
|
||||
**状态转换**:
|
||||
|
||||
| 当前状态 | 触发 | 目标状态 |
|
||||
|---|---|---|
|
||||
| stateInput | 输入 `/` 或 `/xxx` | statePalette |
|
||||
| stateInput | 输入命令文本(如 `issue +list`) | stateExecuting |
|
||||
| stateInput | 输入 `exit`/`quit`/Ctrl+D | 退出程序 |
|
||||
| statePalette | 选中命令(无参数) | stateExecuting |
|
||||
| statePalette | 选中命令(有参数) | stateForm |
|
||||
| statePalette | Esc | stateInput |
|
||||
| stateForm | 提交(校验通过) | stateExecuting |
|
||||
| stateForm | Esc | statePalette |
|
||||
| stateExecuting | 执行完成 | stateInput |
|
||||
|
||||
**直接命令执行**:
|
||||
|
||||
用户在输入框直接输入 `issue +list --state open` 时:
|
||||
1. 解析为 group=`issue`、command=`+list`、flags=`--state open`
|
||||
2. 查找匹配的 Shortcut
|
||||
3. 构建 flag map,直接执行
|
||||
4. 如果参数不完整,进入 stateForm 补充必填参数
|
||||
|
||||
### 3. palette.go — 命令面板
|
||||
|
||||
基于 `bubbles/list` 组件。
|
||||
|
||||
**两级分组实现**:
|
||||
|
||||
```go
|
||||
type paletteLevel int
|
||||
|
||||
const (
|
||||
levelGroup paletteLevel = iota // 选择命令组
|
||||
levelCommand // 选择子命令
|
||||
)
|
||||
|
||||
type paletteModel struct {
|
||||
list list.Model // bubbles/list 组件
|
||||
level paletteLevel // 当前层级
|
||||
groups []string // 所有命令组名
|
||||
selected string // Level1 选中的组名
|
||||
shortcuts map[string][]*common.Shortcut
|
||||
}
|
||||
```
|
||||
|
||||
**Level 1(命令组列表)**:
|
||||
- 每个 item 显示:组名 + 描述
|
||||
- 支持模糊搜索过滤
|
||||
|
||||
**Level 2(子命令列表)**:
|
||||
- 标题显示当前组名(如 "issue")
|
||||
- 每个 item 显示:命令名(+list)+ 描述
|
||||
- 支持模糊搜索过滤
|
||||
- Esc 返回 Level 1
|
||||
|
||||
**模糊搜索**:使用 bubbles/list 内置的 Filter,按关键词匹配 item 的标题和描述。
|
||||
|
||||
### 4. form.go — 参数表单
|
||||
|
||||
基于 `huh` 库动态生成表单。
|
||||
|
||||
```go
|
||||
type formModel struct {
|
||||
form *huh.Form
|
||||
shortcut *common.Shortcut
|
||||
group string
|
||||
values map[string]string // 用户填写的参数值
|
||||
}
|
||||
```
|
||||
|
||||
**表单生成逻辑**:
|
||||
|
||||
```
|
||||
对于选中的 Shortcut:
|
||||
1. 遍历 s.Flags
|
||||
2. 每个 Flag 生成一个 huh.Field:
|
||||
- Required=true → huh.NewInput().Title("参数名 *").Validate(非空校验)
|
||||
- Required=false → huh.NewInput().Title("参数名")
|
||||
- Bool=true → huh.NewConfirm().Title("参数名")
|
||||
- Default!="" → 设置默认值
|
||||
3. 按 huh.NewGroup(fields...).Title("group +command") 组织
|
||||
4. 创建 huh.NewForm(group)
|
||||
```
|
||||
|
||||
**参数校验**:
|
||||
- 必填参数:提交时检查非空,为空则显示 "XX 为必填参数,不能为空"
|
||||
- 提交成功后返回 `values` map 传递给执行器
|
||||
|
||||
### 5. executor.go — 命令执行器
|
||||
|
||||
桥接层:将表单收集的参数转换为 RuntimeContext 调用现有 Shortcut.Run。
|
||||
|
||||
```go
|
||||
type Executor struct {
|
||||
owner string
|
||||
repo string
|
||||
format string
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (string, error) {
|
||||
// 1. 创建 RuntimeContext
|
||||
ctx, err := common.NewRuntimeContext(args)
|
||||
// 2. 捕获 stdout(重定向 fmt.Print 输出)
|
||||
// 3. 调用 s.Run(ctx)
|
||||
// 4. 返回捕获的输出字符串
|
||||
}
|
||||
```
|
||||
|
||||
**输出捕获**:
|
||||
- 执行命令时将 `os.Stdout` 临时重定向到 `bytes.Buffer`
|
||||
- 执行完成后恢复
|
||||
- 将 Buffer 内容作为字符串返回给 REPL 显示
|
||||
|
||||
### 6. 对现有代码的修改
|
||||
|
||||
**cmd/root.go**(新增 1 行):
|
||||
```go
|
||||
rootCmd.AddCommand(interactive.NewInteractiveCmd())
|
||||
```
|
||||
|
||||
**shortcuts/register.go**(新增 1 个导出函数):
|
||||
```go
|
||||
// GetAllShortcuts 返回所有命令组的元数据映射,供交互模式使用
|
||||
func GetAllShortcuts() map[string][]*common.Shortcut {
|
||||
// 返回现有的 allShortcuts 映射
|
||||
}
|
||||
```
|
||||
|
||||
## UI 视觉规范
|
||||
|
||||
### REPL 提示符
|
||||
|
||||
```
|
||||
gitlink (owner/repo)> █ ← 在 git 仓库中
|
||||
gitlink> █ ← 不在 git 仓库中
|
||||
```
|
||||
|
||||
### 命令面板 — Level 1
|
||||
|
||||
```
|
||||
╭─────────────────────────────────────────╮
|
||||
│ 搜索: iss█ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ❯ issue Issue 管理 │
|
||||
│ snippet 代码片段管理 │
|
||||
│ │
|
||||
│ │
|
||||
│ ↑↓ 选择 Enter 确认 Esc 返回 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 命令面板 — Level 2
|
||||
|
||||
```
|
||||
╭─ issue ─────────────────────────────────╮
|
||||
│ 搜索: █ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ❯ +list 列出 issue │
|
||||
│ +create 创建 issue │
|
||||
│ +view 查看 issue 详情 │
|
||||
│ +close 关闭 issue │
|
||||
│ +update 更新 issue │
|
||||
│ +comment 评论 issue │
|
||||
│ │
|
||||
│ ↑↓ 选择 Enter 确认 Esc 返回上级 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 参数表单
|
||||
|
||||
```
|
||||
╭─ issue +create ─────────────────────────╮
|
||||
│ │
|
||||
│ Title * [我的 issue 标题 ] │
|
||||
│ Body [描述内容... ] │
|
||||
│ Labels [bug, enhancement ] │
|
||||
│ Assignee [ ] │
|
||||
│ │
|
||||
│ * 为必填参数 │
|
||||
│ │
|
||||
│ Tab 下一字段 Enter 执行 Esc 取消 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 命令执行结果
|
||||
|
||||
```
|
||||
✓ 执行: issue +create --title "我的 issue 标题"
|
||||
|
||||
#123 我的 issue 标题
|
||||
状态: 开启
|
||||
作者: username
|
||||
创建时间: 2026-06-08 15:30
|
||||
|
||||
gitlink (owner/repo)> █
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```
|
||||
╭─ 错误 ──────────────────────────────────╮
|
||||
│ ✗ 参数不完整 │
|
||||
│ │
|
||||
│ --title 为必填参数,不能为空 │
|
||||
│ │
|
||||
│ 按 Enter 返回表单重新填写 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|---|---|
|
||||
| 未登录就执行命令 | 显示 "请先登录:gitlink auth login" 并提示如何操作 |
|
||||
| 命令执行 API 错误 | 内联显示错误信息(红色),回到输入提示符 |
|
||||
| 表单必填参数为空 | 字段边框变红,底部显示 "XX 为必填参数" |
|
||||
| 网络错误 | 显示 "网络错误: ...",回到输入提示符 |
|
||||
| 无效命令 | 显示 "未知命令: xxx,输入 / 查看所有命令" |
|
||||
|
||||
## 测试策略
|
||||
|
||||
1. **单元测试**:executor 的参数转换和输出捕获
|
||||
2. **集成测试**:palette 的模糊搜索过滤、form 的校验逻辑
|
||||
3. **手动测试**:完整 REPL 交互流程
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 命令历史记录(上下箭头翻历史)— 可作为后续增强
|
||||
- 命令别名/快捷方式
|
||||
- 多选批量操作
|
||||
- 输出结果分页(使用 less)
|
||||
- 语法高亮
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
class GitlinkCli < Formula
|
||||
desc "CLI tool for GitLink platform"
|
||||
homepage "https://www.gitlink.org.cn/Gitlink/gitlink-cli"
|
||||
version "0.1.13"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_arm64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_amd64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_arm64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_amd64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "gitlink-cli"
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/gitlink-cli", "version"
|
||||
end
|
||||
end
|
||||
26
go.mod
26
go.mod
|
|
@ -1,8 +1,11 @@
|
|||
module github.com/gitlink-org/gitlink-cli
|
||||
|
||||
go 1.26.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/term v0.41.0
|
||||
|
|
@ -10,11 +13,32 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
|
|
|||
58
go.sum
58
go.sum
|
|
@ -1,9 +1,39 @@
|
|||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
|
@ -12,12 +42,32 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
|
|
@ -27,13 +77,21 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
|||
|
|
@ -150,3 +150,157 @@ func GetCurrentUser() (map[string]interface{}, error) {
|
|||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OAuthTokenResult holds the response from an OAuth2 token request.
|
||||
type OAuthTokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
// LoginOAuth authenticates via OAuth2 password grant and stores the access token.
|
||||
func LoginOAuth(username, password string) (*OAuthTokenResult, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Derive the base URL by stripping the /api suffix
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
|
||||
|
||||
tokenURL := baseURL + "/oauth/token"
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "password")
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 token request failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access_token in OAuth2 response")
|
||||
}
|
||||
|
||||
// Store the access token
|
||||
if err := StoreToken(tokenResp.AccessToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
// Get current user info to display login name
|
||||
user, err := GetCurrentUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token stored but failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
|
||||
return &OAuthTokenResult{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
Login: login,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshOAuthToken refreshes the current OAuth2 token using a refresh_token grant.
|
||||
// Note: This requires the server to have issued a refresh_token during the initial login.
|
||||
func RefreshOAuthToken() (*OAuthTokenResult, error) {
|
||||
currentToken, err := LoadToken()
|
||||
if err != nil || currentToken == "" {
|
||||
return nil, fmt.Errorf("no stored token found; please login first")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
|
||||
|
||||
tokenURL := baseURL + "/oauth/token"
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", currentToken)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 token refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access_token in OAuth2 response")
|
||||
}
|
||||
|
||||
// Store the new access token
|
||||
if err := StoreToken(tokenResp.AccessToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
// Get current user info
|
||||
user, err := GetCurrentUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refreshed but failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
|
||||
return &OAuthTokenResult{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
Login: login,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package auth
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
|
@ -41,8 +42,30 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
return filepath.Join(configDir(), "credentials")
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,14 @@ type APIError struct {
|
|||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Method != "" && e.Path != "" {
|
||||
return fmt.Sprintf("%s %s: [%v] %s", e.Method, e.Path, e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +107,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
Method: method,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,11 +130,14 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||
msg, _ := raw["message"].(string)
|
||||
// [P4 改进] 增加 HTTP 状态码到友好提示的映射,替换原来的裸错误信息
|
||||
suggestion := suggestFix(int(statusCode))
|
||||
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
|
||||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Method: method,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,17 +184,23 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
|||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
|
||||
// suggestFix 返回 HTTP 状态码对应的友好修复建议(P4 改进)
|
||||
// 当 API 返回 4xx/5xx 时,在错误信息中附加这些提示,帮助用户快速定位问题
|
||||
func suggestFix(code int) string {
|
||||
// HTTP 状态码 → 中文友好提示映射
|
||||
// 注意:这些提示直接展示给终端用户,使用英文以便国际化兼容
|
||||
switch code {
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
return "Run 'gitlink auth login' to authenticate" // 未认证
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
return "Permission denied. Check your account permissions or contact the project admin" // 权限不足
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
return "Resource not found. Verify --owner, --repo, and the resource ID" // 资源不存在
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
return "Validation failed. Check the request parameters" // 参数校验失败
|
||||
case 500:
|
||||
return "Server error. Try again later or contact the platform admin" // 服务端错误
|
||||
default:
|
||||
return ""
|
||||
return "" // 其他状态码不给出具体建议
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -26,12 +27,32 @@ func DefaultConfig() *Config {
|
|||
}
|
||||
}
|
||||
|
||||
// ConfigDir returns the platform-appropriate configuration directory.
|
||||
// - Windows: %APPDATA%\gitlink-cli
|
||||
// - macOS: ~/Library/Application Support/gitlink-cli
|
||||
// - Linux: ~/.config/gitlink-cli (XDG_CONFIG_HOME respected)
|
||||
func ConfigDir() string {
|
||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigPath() string {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -71,10 +73,13 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
// For maps with nested structures, prefer JSON
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
// If the map contains a list of objects, render that list as a table
|
||||
// (picking the largest such list, which is usually the primary payload).
|
||||
if slice := findLargestSlice(data); slice != nil {
|
||||
return printSliceTable(w, slice)
|
||||
}
|
||||
// Single-object detail: render as a key/value table with nested maps
|
||||
// expanded inline (indented sub-rows) for readability.
|
||||
return printMapTable(w, data)
|
||||
default:
|
||||
// Fallback to JSON
|
||||
|
|
@ -82,32 +87,91 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
}
|
||||
}
|
||||
|
||||
func hasComplexValues(m map[string]interface{}) bool {
|
||||
// findLargestSlice scans the values of m for the largest slice whose elements
|
||||
// are maps (e.g. []interface{} of map[string]interface{}, or native
|
||||
// []map[string]interface{}/[]map[string]string built locally by some commands).
|
||||
// Elements are normalized to map[string]interface{} so the table renderer has a
|
||||
// consistent shape. Returns nil if no suitable slice is found.
|
||||
func findLargestSlice(m map[string]interface{}) []interface{} {
|
||||
var best []interface{}
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
return true
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
// Every element must be a map for it to be table-renderable.
|
||||
normalized := make([]interface{}, 0, rv.Len())
|
||||
ok := true
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
el, alright := normalizeMap(rv.Index(i).Interface())
|
||||
if !alright {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
normalized = append(normalized, el)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(normalized) > len(best) {
|
||||
best = normalized
|
||||
}
|
||||
}
|
||||
return false
|
||||
return best
|
||||
}
|
||||
|
||||
// normalizeMap coerces a map value (map[string]interface{} or a native
|
||||
// map[string]X built by command code) into map[string]interface{} for uniform
|
||||
// rendering. Returns ok=false if v is not a string-keyed map.
|
||||
func normalizeMap(v interface{}) (map[string]interface{}, bool) {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Map {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
for _, key := range rv.MapKeys() {
|
||||
ks, ok := key.Interface().(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[ks] = rv.MapIndex(key).Interface()
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// maxTableColumns caps the number of columns rendered in a slice table so the
|
||||
// output stays readable on standard terminal widths. Fields beyond this limit
|
||||
// are dropped (priority fields are kept first; see collectKeys).
|
||||
const maxTableColumns = 8
|
||||
|
||||
func printSliceTable(w io.Writer, items []interface{}) error {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect headers from first item
|
||||
first, ok := items[0].(map[string]interface{})
|
||||
if !ok {
|
||||
// If the first item isn't a map, fall back to JSON (unchanged behavior).
|
||||
if _, ok := items[0].(map[string]interface{}); !ok {
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := collectKeys(first)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
// Headers are the UNION of every row's keys, so a column appears whenever
|
||||
// any row carries the field. GitLink payloads (e.g. branch lists) do not
|
||||
// always include every key on every item; taking headers from items[0]
|
||||
// alone made columns like `protected` appear or vanish depending on which
|
||||
// item happened to rank first.
|
||||
headers := collectKeysUnion(items)
|
||||
omitted := 0
|
||||
if len(headers) > maxTableColumns {
|
||||
omitted = len(headers) - maxTableColumns
|
||||
headers = headers[:maxTableColumns]
|
||||
}
|
||||
|
||||
// minwidth=0, tabwidth=2, padding=2 -> tighter columns than the old
|
||||
// minwidth=4 setting, which helps when there are several wide fields.
|
||||
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
||||
|
||||
// Print headers
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
|
|
@ -129,31 +193,75 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
}
|
||||
fmt.Fprintln(tw, strings.Join(vals, "\t"))
|
||||
}
|
||||
return tw.Flush()
|
||||
tw.Flush()
|
||||
|
||||
if omitted > 0 {
|
||||
// Note appended after the table so it does not perturb column alignment.
|
||||
fmt.Fprintf(w, "(已省略 %d 列)\n", omitted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printMapTable renders a single-object detail as a two-column KEY/VALUE table.
|
||||
// Top-level keys are ordered by collectKeys (identity/status fields first).
|
||||
// Nested map values are expanded as indented sub-rows so rich detail (e.g. the
|
||||
// commit/tagger block of a tag) stays readable instead of collapsing to a
|
||||
// summary token or a JSON blob.
|
||||
func printMapTable(w io.Writer, m map[string]interface{}) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "KEY\tVALUE")
|
||||
fmt.Fprintln(tw, "---\t-----")
|
||||
for k, v := range m {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
|
||||
for _, k := range collectKeys(m) {
|
||||
writeMapRow(tw, k, m[k], 0)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func collectKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
// Prefer common keys first
|
||||
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
||||
// writeMapRow writes one row for a key/value pair. Nested maps recurse with
|
||||
// increasing indent so sub-fields appear under their parent.
|
||||
func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
displayKey := key
|
||||
if depth > 0 {
|
||||
displayKey = "└ " + key
|
||||
}
|
||||
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
// Parent header row, then each child indented one level deeper.
|
||||
fmt.Fprintf(tw, "%s%s\t\n", indent, displayKey)
|
||||
for _, ck := range collectKeys(m) {
|
||||
writeMapRow(tw, ck, m[ck], depth+1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(tw, "%s%s\t%s\n", indent, displayKey, formatValue(v))
|
||||
}
|
||||
|
||||
// preferredHeaders lists keys that are surfaced first (and therefore survive the
|
||||
// maxTableColumns cap). Keep the most human-meaningful identity/status fields
|
||||
// at the top so that even when a row has 11+ keys the table still shows the
|
||||
// columns a user actually scans.
|
||||
var preferredHeaders = []string{
|
||||
"id", "name", "login", "title", "subject", "number",
|
||||
"status", "state", "protected", "last_commit", "commit_time",
|
||||
"type", "time_ago", "created_time", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
|
||||
// fields come first in their defined order, the rest follow in set-iteration
|
||||
// order. Keys absent from preferredHeaders still appear, just after the
|
||||
// priority fields.
|
||||
func orderKeySet(keySet map[string]bool) []string {
|
||||
keys := make([]string, 0, len(keySet))
|
||||
seen := map[string]bool{}
|
||||
for _, k := range priority {
|
||||
if _, ok := m[k]; ok {
|
||||
keys = append(keys, k)
|
||||
seen[k] = true
|
||||
for _, p := range preferredHeaders {
|
||||
if keySet[p] {
|
||||
keys = append(keys, p)
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
for k := range m {
|
||||
for k := range keySet {
|
||||
if !seen[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
|
@ -161,20 +269,180 @@ func collectKeys(m map[string]interface{}) []string {
|
|||
return keys
|
||||
}
|
||||
|
||||
// collectKeysUnion merges the keys of every map in items into a single
|
||||
// priority-ordered header list. Used by printSliceTable so a column shows
|
||||
// whenever any row has the field — preventing columns from disappearing when
|
||||
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
|
||||
func collectKeysUnion(items []interface{}) []string {
|
||||
keySet := map[string]bool{}
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for k := range m {
|
||||
keySet[k] = true
|
||||
}
|
||||
}
|
||||
return orderKeySet(keySet)
|
||||
}
|
||||
|
||||
func collectKeys(m map[string]interface{}) []string {
|
||||
keySet := make(map[string]bool, len(m))
|
||||
for k := range m {
|
||||
keySet[k] = true
|
||||
}
|
||||
return orderKeySet(keySet)
|
||||
}
|
||||
|
||||
func formatValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Map, reflect.Slice:
|
||||
data, _ := json.Marshal(v)
|
||||
s := string(data)
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
case reflect.Map:
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
return formatNestedMap(m)
|
||||
}
|
||||
return s
|
||||
// Native map (e.g. map[string]string built locally): normalize then summarize.
|
||||
if m, ok := normalizeMap(v); ok {
|
||||
return formatNestedMap(m)
|
||||
}
|
||||
// Non-string-keyed map: fall back to JSON, truncated.
|
||||
data, _ := json.Marshal(v)
|
||||
return truncateString(string(data), 40)
|
||||
case reflect.Slice:
|
||||
// For slices, show the element count rather than an inline JSON blob,
|
||||
// which is almost always wider than the terminal.
|
||||
return fmt.Sprintf("[%d items]", rv.Len())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
f := rv.Float()
|
||||
// Render whole-valued floats as integers (e.g. 1779200970 instead of
|
||||
// 1.77920097e+09); otherwise use plain 'f' formatting to avoid the
|
||||
// scientific notation %v picks for large magnitudes.
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
return truncateString(fmt.Sprintf("%v", v), 40)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateString shortens s to at most maxLen bytes, appending "..." when it is
|
||||
// truncated. Strings already within the limit are returned unchanged.
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// formatNestedMap produces a compact, human-readable summary of a nested map by
|
||||
// extracting a few well-known fields (name/login/title/message/sha/...) instead
|
||||
// of dumping the full JSON. Unknown shapes collapse to "<object>". This keeps
|
||||
// table cells narrow even for richly-nested API payloads (e.g. commit metadata).
|
||||
func formatNestedMap(m map[string]interface{}) string {
|
||||
// Ordered candidates: earlier keys win and become the leading part.
|
||||
type candidate struct {
|
||||
key string
|
||||
max int // 0 = no truncation
|
||||
}
|
||||
cands := []candidate{
|
||||
{"name", 0},
|
||||
{"login", 0},
|
||||
{"title", 30},
|
||||
{"subject", 30},
|
||||
{"message", 30},
|
||||
{"sha", 8},
|
||||
{"id", 0},
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, c := range cands {
|
||||
raw, ok := m[c.key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
s := formatValue(raw)
|
||||
if c.max > 0 {
|
||||
if c.key == "sha" {
|
||||
// Git-style short SHA: bare prefix, no ellipsis.
|
||||
if len(s) > c.max {
|
||||
s = s[:c.max]
|
||||
}
|
||||
} else {
|
||||
s = truncateString(s, c.max)
|
||||
}
|
||||
}
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// For text-ish fields, join with ":"; for sha/id use a bare token.
|
||||
switch c.key {
|
||||
case "sha", "id":
|
||||
parts = append(parts, s)
|
||||
default:
|
||||
parts = append(parts, c.key+":"+s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "<object>"
|
||||
}
|
||||
return strings.Join(parts, " -> ")
|
||||
}
|
||||
|
||||
// ShouldUseColor returns true if colorized output should be used.
|
||||
func ShouldUseColor(mode string) bool {
|
||||
switch mode {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
default: // "auto"
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("COLORTERM") != "" {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("TERM") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
|
||||
func runeWidth(r rune) int {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
return 2
|
||||
}
|
||||
if r >= 0xFF01 && r <= 0xFF60 {
|
||||
return 2
|
||||
}
|
||||
if r >= 0x3000 && r <= 0x303F {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// StringWidth returns the display width of a string accounting for CJK characters.
|
||||
func StringWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
w += runeWidth(r)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintTable_NestedSliceRendersAsTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1", "id": "a"},
|
||||
map[string]interface{}{"name": "v2", "id": "b"},
|
||||
},
|
||||
"total_count": 2,
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"name", "v1", "v2", "id"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Must NOT start with JSON envelope.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output, not JSON; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTable_NestedMapRendersAsDetailTable 验证含嵌套 map 的单对象详情
|
||||
// 现在渲染为易读的 KEY/VALUE 表格(嵌套字段用缩进展开),而非回退 JSON。
|
||||
func TestPrintTable_NestedMapRendersAsDetailTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "v1.0.0",
|
||||
"commit": map[string]interface{}{"sha": "abc123", "message": "release"},
|
||||
"tagger": map[string]interface{}{"login": "alice", "name": "Alice"},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// Should be a key/value table, NOT JSON.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output for nested map, not JSON; got:\n%s", out)
|
||||
}
|
||||
// Top-level key present.
|
||||
for _, want := range []string{"name", "v1.0.0", "KEY", "VALUE"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Nested fields should be expanded inline (not collapsed to <object>).
|
||||
for _, want := range []string{"sha", "abc123", "login", "alice"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected nested field %q to be expanded; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_SimpleMapRendersAsMapTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "x",
|
||||
"count": 5,
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
|
||||
t.Errorf("expected KEY/VALUE map table; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTable_NativeMapStringSlice 验证本地构造的 []map[string]string
|
||||
// (如 wiki +list 的 pages)也能渲染为表格,而非折叠成 "[N items]"。
|
||||
// 回归测试:之前 findLargestSlice 只认 []interface{},导致这类列表被当
|
||||
// 单对象详情的 slice 字段,显示为 "[2 items]"。
|
||||
func TestPrintTable_NativeMapStringSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"pages": []map[string]string{
|
||||
{"name": "Home"},
|
||||
{"name": "About"},
|
||||
},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "[2 items]") {
|
||||
t.Errorf("native map slice should render as table, not [N items]; got:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"name", "Home", "About"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_ErrorEnvelope(t *testing.T) {
|
||||
env := ErrorEnvelope("E_FAIL", "boom", "")
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Error:") {
|
||||
t.Errorf("expected output to contain \"Error:\"; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "boom") {
|
||||
t.Errorf("expected output to contain error message \"boom\"; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_PicksLargestSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"small": []interface{}{
|
||||
map[string]interface{}{"a": 1},
|
||||
},
|
||||
"big": []interface{}{
|
||||
map[string]interface{}{"x": 1},
|
||||
map[string]interface{}{"x": 2},
|
||||
map[string]interface{}{"x": 3},
|
||||
},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// Must not be a JSON envelope fallback.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output, not JSON; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "x") {
|
||||
t.Errorf("expected output to render \"big\" list with x column; got:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"1", "2", "3"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain row value %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
// header + dash line + 3 rows = 5 lines minimum
|
||||
if len(lines) < 5 {
|
||||
t.Errorf("expected at least 5 lines (header/dash/3 rows); got %d:\n%s", len(lines), out)
|
||||
}
|
||||
// The "a" column from the smaller slice must not appear as a header.
|
||||
for _, line := range lines {
|
||||
// header line is tab-separated; ensure "a" is not one of the columns.
|
||||
cols := strings.Split(line, "\t")
|
||||
for _, c := range cols {
|
||||
if strings.TrimSpace(c) == "a" {
|
||||
t.Errorf("did not expect \"a\" column from smaller slice; got line: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Readability improvements (truncation, column limits, nested-map summaries) ---
|
||||
|
||||
func TestFormatValue_TruncatesLongString(t *testing.T) {
|
||||
long := strings.Repeat("a", 100)
|
||||
got := formatValue(long)
|
||||
if len(got) > 40 {
|
||||
t.Errorf("expected output <= 40 chars, got %d: %q", len(got), got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("expected output to end with \"...\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_ShortStringUntouched(t *testing.T) {
|
||||
got := formatValue("hello")
|
||||
if got != "hello" {
|
||||
t.Errorf("expected short string unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NestedMapShowsSummary(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"login": "alice",
|
||||
"id": 123,
|
||||
"image_url": "http://example.com/avatar.png",
|
||||
}
|
||||
got := formatValue(m)
|
||||
if !strings.Contains(got, "alice") {
|
||||
t.Errorf("expected summary to contain \"alice\", got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "image_url") {
|
||||
t.Errorf("expected summary to omit noisy field \"image_url\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NestedMapWithoutKnownFields(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"baz": 42,
|
||||
}
|
||||
got := formatValue(m)
|
||||
// No recognized key -> should be a short placeholder, not a JSON blob.
|
||||
if strings.HasPrefix(got, "{") {
|
||||
t.Errorf("expected placeholder for object without known fields, got JSON: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_FloatNoScientificNotation(t *testing.T) {
|
||||
got := formatValue(1.77920097e+09)
|
||||
if strings.Contains(got, "e+") || strings.Contains(got, "E+") {
|
||||
t.Errorf("expected float without scientific notation, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "1779200970") {
|
||||
t.Errorf("expected full decimal expansion, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintSliceTable_LimitsColumns(t *testing.T) {
|
||||
// Build a single row with 12 columns.
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"id", "name", "login", "title", "subject", "number",
|
||||
"status", "state", "extra1", "extra2", "extra3", "extra4"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v_" + c
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// The header line is the first non-empty line.
|
||||
lines := strings.Split(out, "\n")
|
||||
var header string
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
header = l
|
||||
break
|
||||
}
|
||||
}
|
||||
colCount := len(strings.Split(header, "\t"))
|
||||
if colCount > 8 {
|
||||
t.Errorf("expected <= 8 columns in header, got %d:\n%s", colCount, header)
|
||||
}
|
||||
// And the output should note that columns were omitted.
|
||||
if !strings.Contains(out, "省略") && !strings.Contains(strings.ToLower(out), "omitted") {
|
||||
t.Errorf("expected output to mention omitted columns (省略/omitted), got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintSliceTable_PreservesPriorityHeaders(t *testing.T) {
|
||||
// Even with >8 columns, priority fields like id/name should survive.
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
|
||||
t.Errorf("expected priority headers id/name to survive; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
if got := truncateString("abc", 10); got != "abc" {
|
||||
t.Errorf("expected short string unchanged, got %q", got)
|
||||
}
|
||||
got := truncateString("abcdefghij", 5)
|
||||
if len(got) > 5 {
|
||||
t.Errorf("expected len <= 5, got %d: %q", len(got), got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("expected ... suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNestedMap_LoginAndSha(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"login": "bob",
|
||||
"sha": "0123456789abcdef0123456789abcdef",
|
||||
}
|
||||
got := formatNestedMap(m)
|
||||
if !strings.Contains(got, "bob") {
|
||||
t.Errorf("expected login in summary, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "0123456") {
|
||||
t.Errorf("expected truncated sha prefix in summary, got %q", got)
|
||||
}
|
||||
// Should not contain the full sha.
|
||||
if strings.Contains(got, "0123456789abcdef0123456789abcdef") {
|
||||
t.Errorf("expected sha to be truncated, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSliceTable_HeaderUnionAcrossRows 验证当各行字段集合不一致时
|
||||
// (GitLink 分支列表的典型情况:并非每个分支都返回 protected 字段),
|
||||
// 表头应取所有行的字段并集,而不是仅取第一行。否则 protected 列会因落在
|
||||
// 第一位的样本恰好缺失而「时有时无」。
|
||||
func TestPrintSliceTable_HeaderUnionAcrossRows(t *testing.T) {
|
||||
items := []interface{}{
|
||||
map[string]interface{}{"name": "develop"}, // 第一行缺 protected
|
||||
map[string]interface{}{"name": "main", "protected": false}, // 第二行带 protected
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, items); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "protected") {
|
||||
t.Errorf("expected header to include \"protected\" from row union (not just first row); got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "develop") || !strings.Contains(out, "main") {
|
||||
t.Errorf("expected both rows present in output; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSliceTable_ProtectedSurvivesColumnCap 验证当列数超过上限时,
|
||||
// protected(是否保护分支)作为优先字段会被保留、不被截断丢弃——否则在
|
||||
// 字段较多的分支列表里这一列仍可能消失。
|
||||
func TestPrintSliceTable_ProtectedSurvivesColumnCap(t *testing.T) {
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name", "protected"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "protected") {
|
||||
t.Errorf("expected priority field \"protected\" to survive the column cap; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderKeySet_BranchColumnOrderIsStable 守护分支核心列的固定顺序
|
||||
// name / protected / last_commit / commit_time。当前若 last_commit /
|
||||
// commit_time 不在 preferredHeaders,orderKeySet 会把这两个非优先键按 map
|
||||
// 遍历序追加,导致列顺序随机——重复调用必暴露不一致。
|
||||
func TestOrderKeySet_BranchColumnOrderIsStable(t *testing.T) {
|
||||
want := []string{"name", "protected", "last_commit", "commit_time"}
|
||||
keys := map[string]bool{"name": true, "protected": true, "last_commit": true, "commit_time": true}
|
||||
for i := 0; i < 100; i++ {
|
||||
got := orderKeySet(keys)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("iteration %d: got %d keys %v, want %v", i, len(got), got, want)
|
||||
}
|
||||
for j, w := range want {
|
||||
if got[j] != w {
|
||||
t.Fatalf("iteration %d: got[%d]=%q, want %q (full %v)", i, j, got[j], w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
npm/bin/cli.js
124
npm/bin/cli.js
|
|
@ -2,21 +2,119 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const binaryPath = path.join(__dirname, "gitlink-cli" + ext);
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
const PACKAGE = require("../package.json");
|
||||
const CURRENT_VERSION = PACKAGE.version;
|
||||
|
||||
try {
|
||||
execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (err.status !== undefined) {
|
||||
process.exit(err.status);
|
||||
}
|
||||
console.error(`Failed to run gitlink-cli: ${err.message}`);
|
||||
console.error(
|
||||
"Binary may not be installed. Try reinstalling: npm install -g @gitlink-ai/cli"
|
||||
);
|
||||
process.exit(1);
|
||||
function getBinaryName(platform = process.platform) {
|
||||
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
}
|
||||
|
||||
function getBinaryPath(platform = process.platform, baseDir = __dirname) {
|
||||
return path.join(baseDir, getBinaryName(platform));
|
||||
}
|
||||
|
||||
function formatMissingBinaryError(
|
||||
binaryPath,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
) {
|
||||
return [
|
||||
`Error: ${BINARY_NAME} binary not found at ${binaryPath}`,
|
||||
`Platform: ${platform}/${arch}`,
|
||||
"",
|
||||
"The npm package was installed, but the native binary is missing.",
|
||||
"This usually means the release asset for your platform is unavailable or postinstall failed.",
|
||||
"",
|
||||
"Try reinstalling:",
|
||||
" npm install -g @gitlink-ai/cli",
|
||||
"",
|
||||
"If the problem persists, check the GitLink CLI release assets:",
|
||||
" https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function checkForUpdate() {
|
||||
if (process.env.GITLINK_CLI_NO_UPDATE_CHECK) return;
|
||||
const registryUrl = "https://registry.npmjs.org/@gitlink-ai/cli/latest";
|
||||
const mod = registryUrl.startsWith("https") ? https : http;
|
||||
try {
|
||||
mod.get(registryUrl, { timeout: 3000 }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const pkg = JSON.parse(body);
|
||||
const latest = pkg.version;
|
||||
if (latest && latest !== CURRENT_VERSION) {
|
||||
process.stderr.write(
|
||||
`\nA new version of ${BINARY_NAME} is available: ${latest} (current: ${CURRENT_VERSION})\n` +
|
||||
`Update with: npm update -g @gitlink-ai/cli\n\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore parse errors
|
||||
}
|
||||
});
|
||||
}).on("error", () => {
|
||||
// Silently ignore network errors
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore any errors
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = process.argv.slice(2), options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const arch = options.arch || process.arch;
|
||||
const binaryPath = options.binaryPath || getBinaryPath(platform);
|
||||
const execFile = options.execFileSync || execFileSync;
|
||||
const stderr = options.stderr || process.stderr;
|
||||
const exit = options.exit || process.exit;
|
||||
|
||||
function failMissingBinary() {
|
||||
stderr.write(`${formatMissingBinaryError(binaryPath, platform, arch)}\n`);
|
||||
return exit(1);
|
||||
}
|
||||
|
||||
// Binary existence check before any other logic
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return failMissingBinary();
|
||||
}
|
||||
|
||||
// Run optional update check in background (fire-and-forget)
|
||||
if (require.main === module) {
|
||||
checkForUpdate();
|
||||
}
|
||||
|
||||
try {
|
||||
execFile(binaryPath, args, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return failMissingBinary();
|
||||
}
|
||||
if (err.status !== undefined) {
|
||||
return exit(err.status);
|
||||
}
|
||||
stderr.write(`Failed to run ${BINARY_NAME}: ${err.message}\n`);
|
||||
return exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBinaryName,
|
||||
getBinaryPath,
|
||||
formatMissingBinaryError,
|
||||
run,
|
||||
checkForUpdate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"gitlink-cli-install-skills": "bin/install-skills.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"test": "node test/install.test.js && node test/cli.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"gitlink",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execSync } = require("child_process");
|
||||
|
|
@ -13,17 +14,15 @@ const PACKAGE = require("../package.json");
|
|||
const VERSION = PACKAGE.version;
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
|
||||
// GitLink release download base URL
|
||||
// Format: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
|
||||
// Attachment download: https://www.gitlink.org.cn/api/attachments/{attachment_id}
|
||||
const RELEASE_BASE = "https://www.gitlink.org.cn";
|
||||
const REPO_OWNER = "Gitlink";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 120000;
|
||||
|
||||
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
|
|
@ -57,15 +56,60 @@ function getArchiveName(platform, arch) {
|
|||
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function fetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const maxRedirects = options.maxRedirects || 5;
|
||||
let redirectCount = 0;
|
||||
|
||||
// Proxy support: respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY
|
||||
function getProxy(targetUrl) {
|
||||
try {
|
||||
const parsed = new URL(targetUrl);
|
||||
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const np of noProxy) {
|
||||
if (parsed.hostname.endsWith(np) || parsed.hostname === np) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (parsed.protocol === "https:") {
|
||||
return (
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
null
|
||||
);
|
||||
}
|
||||
return (
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function doRequest(currentUrl) {
|
||||
const proxyUrl = getProxy(currentUrl);
|
||||
const mod = currentUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(currentUrl, (res) => {
|
||||
// Follow redirects
|
||||
const parsedUrl = new URL(currentUrl);
|
||||
const reqOptions = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: "GET",
|
||||
timeout: options.timeout || DOWNLOAD_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = mod.get(currentUrl, reqOptions, (res) => {
|
||||
if (
|
||||
(res.statusCode === 301 ||
|
||||
res.statusCode === 302 ||
|
||||
|
|
@ -106,12 +150,36 @@ function fetch(url, options = {}) {
|
|||
});
|
||||
} else {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
|
||||
let downloaded = 0;
|
||||
let lastProgress = 0;
|
||||
res.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
downloaded += chunk.length;
|
||||
// Progress indicator (every 10% or every 512KB)
|
||||
if (totalSize > 0) {
|
||||
const pct = Math.floor((downloaded / totalSize) * 100);
|
||||
if (pct >= lastProgress + 10) {
|
||||
lastProgress = pct;
|
||||
process.stdout.write(`\r Downloading: ${pct}%`);
|
||||
}
|
||||
} else if (downloaded - lastProgress > 512 * 1024) {
|
||||
lastProgress = downloaded;
|
||||
process.stdout.write(
|
||||
`\r Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`
|
||||
);
|
||||
}
|
||||
});
|
||||
res.on("end", () => {
|
||||
if (totalSize > 0 || downloaded > 512 * 1024) {
|
||||
process.stdout.write("\r");
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(60000, () => {
|
||||
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timed out"));
|
||||
});
|
||||
|
|
@ -123,24 +191,21 @@ function fetch(url, options = {}) {
|
|||
|
||||
async function findReleaseAsset(platform, arch) {
|
||||
const archiveName = getArchiveName(platform, arch);
|
||||
const tagName = `v${VERSION}`;
|
||||
|
||||
// Try fetching release info from GitLink API
|
||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
console.log(`Fetching release info from ${apiUrl}`);
|
||||
|
||||
try {
|
||||
const releases = await fetch(apiUrl, { json: true });
|
||||
|
||||
// Find the release matching our version
|
||||
let release = null;
|
||||
const tagName = `v${VERSION}`;
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
release = releases.find(
|
||||
(r) => r.tag_name === tagName || r.tag_name === VERSION
|
||||
);
|
||||
if (!release && releases.length > 0) {
|
||||
// Fall back to latest release
|
||||
release = releases[0];
|
||||
}
|
||||
} else if (releases && releases.releases) {
|
||||
|
|
@ -154,11 +219,9 @@ async function findReleaseAsset(platform, arch) {
|
|||
}
|
||||
|
||||
if (release && release.attachments) {
|
||||
// Try exact match first
|
||||
let asset = release.attachments.find(
|
||||
(a) => a.title === archiveName || a.filename === archiveName
|
||||
);
|
||||
// If no exact match (version mismatch on fallback release), match by platform/arch pattern
|
||||
if (!asset) {
|
||||
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
||||
const pattern = `_${platform}_${arch}${ext}`;
|
||||
|
|
@ -167,9 +230,8 @@ async function findReleaseAsset(platform, arch) {
|
|||
);
|
||||
}
|
||||
if (asset) {
|
||||
// Return the download URL for this attachment
|
||||
let downloadUrl = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
// Ensure absolute URL
|
||||
let downloadUrl =
|
||||
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
if (downloadUrl.startsWith("/")) {
|
||||
downloadUrl = RELEASE_BASE + downloadUrl;
|
||||
}
|
||||
|
|
@ -180,11 +242,63 @@ async function findReleaseAsset(platform, arch) {
|
|||
console.log(`Warning: Could not fetch release info: ${e.message}`);
|
||||
}
|
||||
|
||||
// Fallback: try direct download URL pattern
|
||||
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform) {
|
||||
function extractArchive(archivePath, destDir, platform) {
|
||||
const isWindows = platform === "windows";
|
||||
if (isWindows) {
|
||||
// Use PowerShell with properly escaped paths for Windows
|
||||
const escapedArchive = archivePath.replace(/'/g, "''");
|
||||
const escapedDest = destDir.replace(/'/g, "''");
|
||||
try {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${escapedArchive}' -DestinationPath '${escapedDest}'"`,
|
||||
{ stdio: "pipe" }
|
||||
);
|
||||
} catch {
|
||||
// Fallback: try tar on modern Windows (10+)
|
||||
try {
|
||||
execSync(`tar -xf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
} catch (e2) {
|
||||
throw new Error(
|
||||
`Failed to extract archive. Tried PowerShell Expand-Archive and tar.\n${e2.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
}
|
||||
}
|
||||
|
||||
function classifyError(err) {
|
||||
const code = err.code || (err.cause && err.cause.code) || "";
|
||||
const messages = {
|
||||
ENOTFOUND: "DNS 解析失败,请检查网络连接",
|
||||
ECONNREFUSED: "连接被拒绝,请检查代理设置 (HTTP_PROXY/HTTPS_PROXY)",
|
||||
ETIMEDOUT: "连接超时,请尝试设置 HTTP_PROXY 环境变量",
|
||||
EACCES: "权限不足,请使用管理员权限运行或检查安装目录",
|
||||
EPERM: "权限不足,请使用管理员权限运行或检查安装目录",
|
||||
};
|
||||
return messages[code] || null;
|
||||
}
|
||||
|
||||
function verifyChecksum(filePath, expectedHash) {
|
||||
if (!expectedHash) return true;
|
||||
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
if (hash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum verification failed for ${path.basename(filePath)}\n` +
|
||||
` Expected: ${expectedHash}\n` +
|
||||
` Actual: ${hash}\n` +
|
||||
`The downloaded file may be corrupted or tampered with.`
|
||||
);
|
||||
}
|
||||
console.log(` Checksum verified: SHA256 OK`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform, expectedChecksum) {
|
||||
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
|
||||
|
||||
const data = await fetch(url);
|
||||
|
|
@ -195,15 +309,10 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
fs.writeFileSync(archivePath, data);
|
||||
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
// Extract
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
|
||||
{ stdio: "pipe" }
|
||||
);
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
}
|
||||
// Verify checksum if provided
|
||||
verifyChecksum(archivePath, expectedChecksum);
|
||||
|
||||
extractArchive(archivePath, destDir, platform);
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
// Find the binary in extracted files
|
||||
|
|
@ -211,7 +320,6 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
const binaryPath = path.join(destDir, binaryName);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
// It might be in a subdirectory
|
||||
const files = fs.readdirSync(destDir);
|
||||
for (const file of files) {
|
||||
const subPath = path.join(destDir, file, binaryName);
|
||||
|
|
@ -226,7 +334,6 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
throw new Error(`Binary "${binaryName}" not found after extraction`);
|
||||
}
|
||||
|
||||
// Make executable (not needed on Windows)
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
|
@ -234,9 +341,15 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
let platformInfo = null;
|
||||
let archiveName = null;
|
||||
|
||||
try {
|
||||
const { platform, arch } = getPlatformInfo();
|
||||
platformInfo = getPlatformInfo();
|
||||
const { platform, arch } = platformInfo;
|
||||
archiveName = getArchiveName(platform, arch);
|
||||
console.log(`Platform: ${platform}-${arch}`);
|
||||
console.log(`Expected release asset: ${archiveName}`);
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
if (!fs.existsSync(binDir)) {
|
||||
|
|
@ -248,22 +361,64 @@ async function main() {
|
|||
// If binary already exists, check version matches
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
try {
|
||||
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
|
||||
const output = execSync(`"${binaryPath}" version`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
if (output.includes(VERSION)) {
|
||||
console.log(`${BINARY_NAME} v${VERSION} already installed, skipping download.`);
|
||||
console.log(
|
||||
`${BINARY_NAME} v${VERSION} already installed, skipping download.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
|
||||
} catch (e) {
|
||||
console.log(`${BINARY_NAME} binary exists but is not compatible, re-downloading...`);
|
||||
console.log(
|
||||
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
|
||||
);
|
||||
} catch {
|
||||
console.log(
|
||||
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
||||
await downloadAndExtract(downloadUrl, binDir, platform);
|
||||
|
||||
// Download with retry
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await downloadAndExtract(downloadUrl, binDir, platform, null);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(
|
||||
`Download attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
|
||||
);
|
||||
console.log(`Retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
} catch (err) {
|
||||
const classifiedMsg = classifyError(err);
|
||||
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
|
||||
if (classifiedMsg) {
|
||||
console.error(`\n诊断信息: ${classifiedMsg}`);
|
||||
}
|
||||
if (platformInfo) {
|
||||
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
|
||||
}
|
||||
if (archiveName) {
|
||||
console.error(`Expected release asset: ${archiveName}`);
|
||||
}
|
||||
console.error(
|
||||
`\nYou can install manually:\n` +
|
||||
` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
|
||||
|
|
@ -274,4 +429,15 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlatformInfo,
|
||||
getBinaryName,
|
||||
getArchiveName,
|
||||
findReleaseAsset,
|
||||
classifyError,
|
||||
verifyChecksum,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const cli = require("../bin/cli.js");
|
||||
|
||||
assert.equal(cli.getBinaryName("win32"), "gitlink-cli.exe");
|
||||
assert.equal(cli.getBinaryName("linux"), "gitlink-cli");
|
||||
assert.equal(cli.getBinaryName("darwin"), "gitlink-cli");
|
||||
|
||||
assert.equal(
|
||||
cli.getBinaryPath("win32", "C:\\tmp\\gitlink"),
|
||||
path.join("C:\\tmp\\gitlink", "gitlink-cli.exe")
|
||||
);
|
||||
|
||||
const message = cli.formatMissingBinaryError(
|
||||
"C:\\tmp\\gitlink-cli.exe",
|
||||
"win32",
|
||||
"x64"
|
||||
);
|
||||
assert.match(message, /binary not found/);
|
||||
assert.match(message, /Platform: win32\/x64/);
|
||||
assert.match(message, /npm install -g @gitlink-ai\/cli/);
|
||||
|
||||
let exitCode = null;
|
||||
const stderr = {
|
||||
output: "",
|
||||
write(text) {
|
||||
this.output += text;
|
||||
},
|
||||
};
|
||||
|
||||
cli.run(["version"], {
|
||||
binaryPath: path.join(os.tmpdir(), "gitlink-cli-test-missing-binary"),
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
stderr,
|
||||
exit(code) {
|
||||
exitCode = code;
|
||||
return code;
|
||||
},
|
||||
execFileSync() {
|
||||
throw new Error("execFileSync should not be called for a missing binary");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 1);
|
||||
assert.match(stderr.output, /gitlink-cli binary not found/);
|
||||
assert.match(stderr.output, /Platform: win32\/x64/);
|
||||
|
||||
console.log("cli wrapper tests passed");
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const install = require("../scripts/install.js");
|
||||
const pkg = require("../package.json");
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("win32", "x64"), {
|
||||
platform: "windows",
|
||||
arch: "amd64",
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("win32", "arm64"), {
|
||||
platform: "windows",
|
||||
arch: "arm64",
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("darwin", "arm64"), {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
isWindows: false,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("linux", "x64"), {
|
||||
platform: "linux",
|
||||
arch: "amd64",
|
||||
isWindows: false,
|
||||
});
|
||||
|
||||
assert.equal(install.getBinaryName("windows"), "gitlink-cli.exe");
|
||||
assert.equal(install.getBinaryName("linux"), "gitlink-cli");
|
||||
assert.equal(install.getBinaryName("darwin"), "gitlink-cli");
|
||||
|
||||
assert.equal(
|
||||
install.getArchiveName("windows", "amd64"),
|
||||
`gitlink-cli_${pkg.version}_windows_amd64.zip`
|
||||
);
|
||||
assert.equal(
|
||||
install.getArchiveName("windows", "arm64"),
|
||||
`gitlink-cli_${pkg.version}_windows_arm64.zip`
|
||||
);
|
||||
assert.equal(
|
||||
install.getArchiveName("linux", "amd64"),
|
||||
`gitlink-cli_${pkg.version}_linux_amd64.tar.gz`
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => install.getPlatformInfo("freebsd", "x64"),
|
||||
/Unsupported platform/
|
||||
);
|
||||
|
||||
console.log("install helper tests passed");
|
||||
|
|
@ -74,13 +74,14 @@ rm -rf "$NPM_DIR/skills"
|
|||
cp -r "$PROJECT_DIR/skills" "$NPM_DIR/skills"
|
||||
|
||||
# Ensure bin dir exists and wrapper is executable
|
||||
chmod +x "$NPM_DIR/bin/gitlink-cli"
|
||||
chmod +x "$NPM_DIR/bin/cli.js"
|
||||
chmod +x "$NPM_DIR/bin/install-skills.js"
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Upload dist/*.tar.gz to GitLink Release v${VERSION}"
|
||||
echo " 1. Upload dist/*.tar.gz and dist/*.zip to GitLink Release v${VERSION}"
|
||||
echo " URL: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases"
|
||||
echo ""
|
||||
echo " 2. Publish npm package:"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +upload:上传文件附件
|
||||
{
|
||||
Name: "upload",
|
||||
Description: "上传文件附件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Short: "f", Usage: "Path to the file to upload", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
file, err := ctx.RequireArg("file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"file": file,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/attachments", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:根据 UUID 删除文件附件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "根据 UUID 删除文件附件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "uuid", Short: "u", Usage: "Attachment UUID to delete", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
uuid, err := ctx.RequireArg("uuid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", uuid), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除多个分支",
|
||||
Long: `Delete multiple branches from the current repository in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
with one branch name per line).
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion
|
||||
unless --yes is set.
|
||||
|
||||
Use --dry-run to preview which branches would be deleted without making any
|
||||
changes.`,
|
||||
Example: ` # Delete two branches
|
||||
gitlink branch +batch-delete --names old-feature,deprecated-api
|
||||
|
||||
# Dry-run to preview deletions
|
||||
gitlink branch +batch-delete --names old-feature,deprecated-api --dry-run
|
||||
|
||||
# Delete branches listed in a file
|
||||
gitlink branch +batch-delete --from branches.txt`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
|
||||
{Name: "from", Usage: "File path (one branch name per line)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchProtectShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
Name: "batch-protect",
|
||||
Description: "批量为多个分支开启保护",
|
||||
Long: `Enable branch protection for multiple branches in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
with one branch name per line).
|
||||
|
||||
Protected branches restrict force-pushes and deletion.
|
||||
|
||||
Use --dry-run to preview which branches would be protected without making any
|
||||
changes.`,
|
||||
Example: ` # Protect two branches
|
||||
gitlink branch +batch-protect --names main,develop
|
||||
|
||||
# Dry-run to preview protections
|
||||
gitlink branch +batch-protect --names main,develop --dry-run
|
||||
|
||||
# Protect branches listed in a file
|
||||
gitlink branch +batch-protect --from branches.txt`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
|
||||
{Name: "from", Usage: "File path (one branch name per line)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchProtect,
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-delete ---
|
||||
|
||||
// [B 类批量] 逐个删除分支——POST /branches/delete(不可逆)
|
||||
// 使用 CollectStrings 而非 CollectNumbers,分支名是字符串非数字
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if !dryRun {
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete %d branch(es) from %s/%s", len(names), ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(names, dryRun, "delete", func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d branch(es) failed to delete", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-protect ---
|
||||
|
||||
// [B 类批量] 逐个设置分支保护——POST /protected_branches
|
||||
// 保护后的分支限制 force-push 和删除操作
|
||||
func runBatchProtect(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := common.ProcessBatch(names, dryRun, "protect", func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d branch(es) failed to protect", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// protectedBranchesPath returns the API path for a repo's protected-branches
|
||||
// collection. Unlike most repository endpoints, protected_branches is served
|
||||
// under the legacy /api route with NO /v1 prefix — forgeplus defines it as
|
||||
// /api/:owner/:repo/protected_branches/:branch_name — so RepoPath() is used
|
||||
// directly. (Empirically the /v1 form returns 404.)
|
||||
func protectedBranchesPath(repoPath string) string {
|
||||
return repoPath + "/protected_branches"
|
||||
}
|
||||
|
|
@ -9,9 +9,23 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
newBatchDeleteShortcut(),
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
newBatchProtectShortcut(),
|
||||
// +list:列出仓库中的分支
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List branches",
|
||||
Description: "列出分支",
|
||||
Long: `List all branches in the current repository.
|
||||
|
||||
Shows branch name, protection status, and other details.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List branches
|
||||
gitlink branch +list
|
||||
|
||||
# Show 50 branches per page
|
||||
gitlink branch +list --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
|
|
@ -27,15 +41,31 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
// Project onto stable core columns so the table stays readable
|
||||
// regardless of how many fields the API returns.
|
||||
branches := summarizeBranches(extractBranches(env.Data))
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"branches": branches,
|
||||
"total_count": len(branches),
|
||||
})
|
||||
},
|
||||
},
|
||||
// +create:从指定来源创建新分支
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
Description: "创建分支",
|
||||
Long: `Create a new branch in the current repository.
|
||||
|
||||
The new branch is created from the specified source branch or commit.
|
||||
If --from is omitted, the repository default branch is used.`,
|
||||
Example: ` # Create a branch from the default branch
|
||||
gitlink branch +create --name feature/login
|
||||
|
||||
# Create a branch from a specific branch
|
||||
gitlink branch +create --name hotfix/fix-123 --from main`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit (default: repository default branch)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -43,8 +73,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
// [P1 Bug#2] 原代码: from = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if from == "" {
|
||||
from = "master"
|
||||
from = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"new_branch_name": name,
|
||||
|
|
@ -57,9 +89,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除一个分支
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
Description: "删除分支",
|
||||
Long: `Delete a branch from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a branch
|
||||
gitlink branch +delete --name old-feature`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -68,6 +106,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete branch %s", name),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
|
|
@ -78,9 +121,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +protect:为分支开启保护
|
||||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
Description: "开启分支保护",
|
||||
Long: `Enable branch protection for the specified branch.
|
||||
|
||||
Protected branches restrict force-pushes and deletion.`,
|
||||
Example: ` # Protect the main branch
|
||||
gitlink branch +protect --name main`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -92,16 +141,22 @@ func Shortcuts() []*common.Shortcut {
|
|||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
env, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unprotect:移除分支保护
|
||||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
Description: "移除分支保护",
|
||||
Long: `Remove branch protection from the specified branch.
|
||||
|
||||
Once unprotected, the branch can be force-pushed or deleted.`,
|
||||
Example: ` # Remove protection from a branch
|
||||
gitlink branch +unprotect --name develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -110,7 +165,66 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
env, err := ctx.CallAPI("DELETE", protectedBranchesPath(ctx.RepoPath())+"/"+url.PathEscape(name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +all:不分页列出全部分支
|
||||
{
|
||||
Name: "all",
|
||||
Description: "列出全部分支(不分页)",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +default:修改仓库的默认分支
|
||||
{
|
||||
Name: "default",
|
||||
Description: "修改默认分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"default_branch": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restore:恢复一个已删除的分支
|
||||
{
|
||||
Name: "restore",
|
||||
Description: "恢复已删除的分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to restore", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// summarizeBranches projects raw branch objects onto a stable set of core
|
||||
// columns — name / protected / last_commit / commit_time — so the table no
|
||||
// longer depends on how many fields the API returns or their ordering. Noisy
|
||||
// fields (URLs, full SHA, etc.) are dropped; they remain available via the
|
||||
// single-branch detail command.
|
||||
func summarizeBranches(items []interface{}) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": m["name"],
|
||||
"protected": m["protected"],
|
||||
"last_commit": formatLastCommit(m),
|
||||
"commit_time": formatCommitTimeField(m["commit_time"]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatLastCommit renders the last-commit column as "short_sha · time_ago",
|
||||
// reading the stable top-level commit_id / commit_time_from_now fields rather
|
||||
// than the inconsistently-shaped nested last_commit object.
|
||||
func formatLastCommit(branch map[string]interface{}) string {
|
||||
sha := ""
|
||||
if v, ok := branch["commit_id"]; ok && v != nil {
|
||||
sha = shortSHA(fmt.Sprintf("%v", v))
|
||||
}
|
||||
rel := ""
|
||||
if v, ok := branch["commit_time_from_now"]; ok && v != nil {
|
||||
rel = strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
switch {
|
||||
case sha != "" && rel != "":
|
||||
return sha + " · " + rel
|
||||
case sha != "":
|
||||
return sha
|
||||
default:
|
||||
return rel
|
||||
}
|
||||
}
|
||||
|
||||
// shortSHA returns the first 8 chars of a git SHA, like git's short names.
|
||||
func shortSHA(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatCommitTimeField trims an ISO-8601 timestamp to a readable
|
||||
// "YYYY-MM-DD HH:MM" form. nil/short values are returned as-is (or "").
|
||||
func formatCommitTimeField(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if len(s) < 16 {
|
||||
return s
|
||||
}
|
||||
return strings.Replace(s[:16], "T", " ", 1)
|
||||
}
|
||||
|
||||
// extractBranches pulls the list of branch objects from an API payload that is
|
||||
// either a top-level array or a map wrapping the list under a key.
|
||||
func extractBranches(data interface{}) []interface{} {
|
||||
switch d := data.(type) {
|
||||
case []interface{}:
|
||||
return d
|
||||
case map[string]interface{}:
|
||||
var best []interface{}
|
||||
for _, v := range d {
|
||||
var slice []interface{}
|
||||
switch sv := v.(type) {
|
||||
case []interface{}:
|
||||
slice = sv
|
||||
case []map[string]interface{}:
|
||||
for _, x := range sv {
|
||||
slice = append(slice, x)
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if len(slice) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := slice[0].(map[string]interface{}); !ok {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(slice) > len(best) {
|
||||
best = slice
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package branch
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestProtectedBranchesPath_NoV1Prefix 验证保护分支接口走的是老 API(无 /v1 前缀)。
|
||||
// 实测 DELETE /v1/.../protected_branches/<name>.json 返回 404;forgeplus 路由为
|
||||
// /api/:owner/:repo/protected_branches/:branch_name(无 v1),故直接用 RepoPath() 拼接。
|
||||
func TestProtectedBranchesPath_NoV1Prefix(t *testing.T) {
|
||||
got := protectedBranchesPath("/owner/repo")
|
||||
want := "/owner/repo/protected_branches"
|
||||
if got != want {
|
||||
t.Errorf("protectedBranchesPath(%q) = %q, want %q (protected_branches is the legacy /api route, no /v1)",
|
||||
"/owner/repo", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummarizeBranches_ProjectsCoreColumns 验证分支列表被投影到固定的 4 列
|
||||
// name/protected/last_commit/commit_time,且噪声字段(URL、完整 SHA 等)被丢弃,
|
||||
// 不再因字段多少或顺序导致表格不稳定。
|
||||
func TestSummarizeBranches_ProjectsCoreColumns(t *testing.T) {
|
||||
branches := []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "main",
|
||||
"protected": true,
|
||||
"commit_id": "e21237dc877b21b009c27554c9aa61ec819ea67d",
|
||||
"commit_time_from_now": "29天前",
|
||||
"commit_time": "2023-12-05T15:06:05+08:00",
|
||||
"http_url": "http://example.com/repo.git",
|
||||
"zip_url": "http://example.com/archive.zip",
|
||||
},
|
||||
}
|
||||
got := summarizeBranches(branches)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(got))
|
||||
}
|
||||
row := got[0]
|
||||
if row["name"] != "main" {
|
||||
t.Errorf("name = %v, want main", row["name"])
|
||||
}
|
||||
if row["protected"] != true {
|
||||
t.Errorf("protected = %v, want true", row["protected"])
|
||||
}
|
||||
if row["last_commit"] != "e21237dc · 29天前" {
|
||||
t.Errorf("last_commit = %v, want \"e21237dc · 29天前\"", row["last_commit"])
|
||||
}
|
||||
if row["commit_time"] != "2023-12-05 15:06" {
|
||||
t.Errorf("commit_time = %v, want \"2023-12-05 15:06\"", row["commit_time"])
|
||||
}
|
||||
for _, bad := range []string{"http_url", "zip_url", "commit_id", "commit_time_from_now"} {
|
||||
if _, ok := row[bad]; ok {
|
||||
t.Errorf("noisy field %q should be dropped from summary", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBranches_FromSliceAndMap 验证从顶层数组或 map 包裹的列表中
|
||||
// 都能取出分支对象列表。
|
||||
func TestExtractBranches_FromSliceAndMap(t *testing.T) {
|
||||
if s := extractBranches([]interface{}{map[string]interface{}{"name": "a"}}); len(s) != 1 {
|
||||
t.Errorf("top-level slice: expected 1, got %d", len(s))
|
||||
}
|
||||
m := extractBranches(map[string]interface{}{
|
||||
"branches": []interface{}{map[string]interface{}{"name": "a"}, map[string]interface{}{"name": "b"}},
|
||||
"total_count": 2,
|
||||
})
|
||||
if len(m) != 2 {
|
||||
t.Errorf("map payload: expected 2 branches, got %d", len(m))
|
||||
}
|
||||
if extractBranches("not-a-list") != nil {
|
||||
t.Errorf("expected nil for unsupported payload type")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,43 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +builds:列出 CI 构建记录
|
||||
{
|
||||
Name: "builds",
|
||||
Description: "List CI builds",
|
||||
Description: "列出 CI 构建记录",
|
||||
Long: `List CI/CD builds for the current repository.
|
||||
|
||||
Shows build number, status, branch, event type, and duration.
|
||||
Use --status, --branch, --event, --since, --until for filtering,
|
||||
and --page and --limit for pagination.`,
|
||||
Example: ` # List recent builds
|
||||
gitlink ci +builds
|
||||
|
||||
# Show 50 builds
|
||||
gitlink ci +builds --limit 50
|
||||
|
||||
# Filter by status
|
||||
gitlink ci +builds --status success
|
||||
gitlink ci +builds -s failure
|
||||
|
||||
# Filter by branch
|
||||
gitlink ci +builds --branch main
|
||||
gitlink ci +builds -b develop
|
||||
|
||||
# Filter by trigger event
|
||||
gitlink ci +builds --event push
|
||||
gitlink ci +builds --event pull_request
|
||||
|
||||
# Filter by time range
|
||||
gitlink ci +builds --since 1700000000 --until 1700086400`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "status", Short: "s", Usage: "Filter by build status (success, failure, running)"},
|
||||
{Name: "branch", Short: "b", Usage: "Filter by branch"},
|
||||
{Name: "event", Usage: "Filter by trigger event (push, pull_request, tag)"},
|
||||
{Name: "since", Usage: "Start time (UNIX timestamp)"},
|
||||
{Name: "until", Usage: "End time (UNIX timestamp)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -23,6 +54,21 @@ func Shortcuts() []*common.Shortcut {
|
|||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if s := ctx.Arg("status"); s != "" {
|
||||
q.Set("status", s)
|
||||
}
|
||||
if b := ctx.Arg("branch"); b != "" {
|
||||
q.Set("branch", b)
|
||||
}
|
||||
if e := ctx.Arg("event"); e != "" {
|
||||
q.Set("event", e)
|
||||
}
|
||||
if s := ctx.Arg("since"); s != "" {
|
||||
q.Set("since", s)
|
||||
}
|
||||
if u := ctx.Arg("until"); u != "" {
|
||||
q.Set("until", u)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -30,9 +76,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +logs:查看构建日志
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "View build logs",
|
||||
Description: "查看构建日志",
|
||||
Long: `View logs for a specific CI/CD build step.
|
||||
|
||||
Requires the build number. Use --stage and --step to navigate
|
||||
to a particular stage and step within the build.`,
|
||||
Example: ` # View logs for build #42
|
||||
gitlink ci +logs --build 42
|
||||
|
||||
# View logs for stage 2, step 3 of build #42
|
||||
gitlink ci +logs --build 42 --stage 2 --step 3`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
||||
|
|
@ -58,9 +114,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restart:重新触发构建
|
||||
{
|
||||
Name: "restart",
|
||||
Description: "Restart a build",
|
||||
Description: "重新触发构建",
|
||||
Long: `Restart a previously completed or failed CI/CD build.
|
||||
|
||||
The build will be re-triggered with the same commit and configuration.`,
|
||||
Example: ` # Restart build #42
|
||||
gitlink ci +restart --build 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -76,9 +138,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stop:停止构建
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop a build",
|
||||
Description: "停止构建",
|
||||
Long: `Stop a currently running CI/CD build.
|
||||
|
||||
Prompts for confirmation before stopping the build.`,
|
||||
Example: ` # Stop build #42
|
||||
gitlink ci +stop --build 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -87,6 +155,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("stop build #%s", build),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -94,5 +167,218 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Pipeline / Actions group ---
|
||||
|
||||
// +pipelines:列出 CI 流水线模板
|
||||
{
|
||||
Name: "pipelines",
|
||||
Description: "列出 CI 流水线模板",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-pipeline:运行 CI 流水线
|
||||
{
|
||||
Name: "run-pipeline",
|
||||
Description: "运行 CI 流水线",
|
||||
Flags: []common.Flag{
|
||||
{Name: "pipeline", Short: "p", Usage: "Pipeline name or ID", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Branch to run on"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pipeline, err := ctx.RequireArg("pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"pipeline": pipeline,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +runs:列出 CI 运行历史
|
||||
{
|
||||
Name: "runs",
|
||||
Description: "列出 CI 运行历史",
|
||||
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/actions/runs", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +save-yaml:保存流水线 YAML 配置
|
||||
{
|
||||
Name: "save-yaml",
|
||||
Description: "保存流水线 YAML 配置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "yaml", Short: "y", Usage: "YAML content", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Pipeline name"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
yamlContent, err := ctx.RequireArg("yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"yaml": yamlContent,
|
||||
}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/pipelines/save_yaml", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +pipeline-detail:查看流水线详情
|
||||
{
|
||||
Name: "pipeline-detail",
|
||||
Description: "查看流水线详情",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-pipeline:删除流水线
|
||||
{
|
||||
Name: "delete-pipeline",
|
||||
Description: "删除流水线",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +disable:为仓库禁用 CI
|
||||
{
|
||||
Name: "disable",
|
||||
Description: "为仓库禁用 CI",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/disable", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +enable:为仓库启用 CI
|
||||
{
|
||||
Name: "enable",
|
||||
Description: "为仓库启用 CI",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/enable", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-log:获取 CI 运行日志
|
||||
{
|
||||
Name: "run-log",
|
||||
Description: "获取 CI 运行日志",
|
||||
Flags: []common.Flag{
|
||||
{Name: "run", Short: "r", Usage: "Run ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := ctx.RequireArg("run")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs/%s/jobs/0", ctx.RepoPath(), run), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-results:获取 CI 运行结果报告
|
||||
{
|
||||
Name: "run-results",
|
||||
Description: "获取 CI 运行结果报告",
|
||||
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/pipelines/run_results", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// userRole 保存用户名和对应角色,支持 CSV 每行指定不同 role
|
||||
// 这是 collaborator 批量命令特有的需求:不同用户可能分配不同的权限
|
||||
// 而 --users + --role 方式所有用户同一个 role,CSV 方式每行可不同
|
||||
type userRole struct {
|
||||
User string
|
||||
Role string
|
||||
}
|
||||
|
||||
func newBatchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-add:批量添加多名协作者
|
||||
Name: "batch-add",
|
||||
Description: "批量添加多名协作者",
|
||||
Long: `Add multiple collaborators to the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
file with user_id and role columns). When --from supplies a role column that
|
||||
value is used; otherwise the --role flag (default: write) applies.
|
||||
|
||||
Use --dry-run to preview which collaborators would be added without making
|
||||
any changes.
|
||||
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Add two collaborators with the default write role
|
||||
gitlink collaborator +batch-add --users zhangsan,lisi
|
||||
|
||||
# Add collaborators with admin role
|
||||
gitlink collaborator +batch-add --users zhangsan,lisi --role admin
|
||||
|
||||
# Dry-run to preview additions from a CSV file
|
||||
gitlink collaborator +batch-add --from collaborators.csv --dry-run
|
||||
|
||||
# Combine both sources
|
||||
gitlink collaborator +batch-add --users zhangsan --from collaborators.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames to add"},
|
||||
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
|
||||
{Name: "role", Short: "r", Usage: "Default role for --users: admin, write, read", Default: "write"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAdd,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchRemoveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-remove:批量移除多名协作者
|
||||
Name: "batch-remove",
|
||||
Description: "批量移除多名协作者",
|
||||
Long: `Remove multiple collaborators from the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
file with a user_id column).
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before removal
|
||||
unless --yes is set.
|
||||
|
||||
Use --dry-run to preview which collaborators would be removed without making
|
||||
any changes.`,
|
||||
Example: ` # Remove two collaborators
|
||||
gitlink collaborator +batch-remove --users zhangsan,lisi
|
||||
|
||||
# Dry-run to preview removals
|
||||
gitlink collaborator +batch-remove --users zhangsan,lisi --dry-run
|
||||
|
||||
# Remove collaborators listed in a CSV file
|
||||
gitlink collaborator +batch-remove --from users.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames to remove"},
|
||||
{Name: "from", Usage: "CSV file path (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchRemove,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchRoleShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
Name: "batch-role",
|
||||
Description: "批量修改多名协作者的角色",
|
||||
Long: `Change the role of multiple collaborators in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) combined with
|
||||
--role, or via --from (CSV file with user_id and role columns).
|
||||
|
||||
Use --dry-run to preview which role changes would be applied without making
|
||||
any changes.
|
||||
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Change two collaborators to admin
|
||||
gitlink collaborator +batch-role --users zhangsan,lisi --role admin
|
||||
|
||||
# Dry-run to preview role changes from a CSV file
|
||||
gitlink collaborator +batch-role --from roles.csv --dry-run
|
||||
|
||||
# Combine both sources
|
||||
gitlink collaborator +batch-role --users zhangsan --role read --from roles.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames"},
|
||||
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
|
||||
{Name: "role", Short: "r", Usage: "New role for --users: admin, write, read"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchRole,
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-add ---
|
||||
|
||||
// [B 类批量] 逐个添加协作者——POST /collaborators
|
||||
// 支持 --users + --role 统一角色 或 --from CSV 每行不同角色
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pairs, err := collectUserRoles(ctx.Arg("users"), ctx.Arg("from"), ctx.Arg("role"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 or --from collaborators.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
items := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
items = append(items, p.User)
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(items, dryRun, "add", func(user string) error {
|
||||
role := roleForUser(pairs, user)
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to add", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-remove ---
|
||||
|
||||
// [B 类批量] 逐个移除协作者——DELETE /collaborators/remove(不可逆)
|
||||
func runBatchRemove(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 or --from users.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if !dryRun {
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("remove %d collaborator(s) from %s/%s", len(users), ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(users, dryRun, "remove", func(user string) error {
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
},
|
||||
}
|
||||
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to remove", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-role ---
|
||||
|
||||
// [B 类批量] 逐个修改协作者角色——PUT /collaborators/change_role
|
||||
func runBatchRole(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersValue := ctx.Arg("users")
|
||||
fromValue := ctx.Arg("from")
|
||||
roleValue := ctx.Arg("role")
|
||||
|
||||
// When --users is used without --from, --role is required.
|
||||
if usersValue != "" && fromValue == "" && roleValue == "" {
|
||||
return fmt.Errorf("--role is required when using --users without --from")
|
||||
}
|
||||
|
||||
pairs, err := collectUserRoles(usersValue, fromValue, roleValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 --role admin or --from roles.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
items := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
items = append(items, p.User)
|
||||
}
|
||||
|
||||
// Resolve all logins to numeric ids once via the collaborators list.
|
||||
memberEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberData, _ := memberEnv.Data.(map[string]interface{})
|
||||
members, _ := memberData["members"].([]interface{})
|
||||
|
||||
summary := common.ProcessBatch(items, dryRun, "change-role", func(user string) error {
|
||||
role := roleForUser(pairs, user)
|
||||
id, found := findUserIDByLogin(members, user)
|
||||
if !found {
|
||||
return fmt.Errorf("collaborator %q not found", user)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user_id": id,
|
||||
"role": mapRole(role),
|
||||
}
|
||||
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to change role", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// collectUserRoles 收集用户-角色对(支持 --users + --role 和 --from CSV 两种输入)
|
||||
// 与 CollectStrings 不同,此函数需要同时处理 {用户, 角色} 成对数据
|
||||
// --from CSV 要求有 user_id 和 role 两列,每行可指定不同角色
|
||||
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
|
||||
var pairs []userRole
|
||||
|
||||
// From --users flag
|
||||
for _, u := range common.ParseStringList(usersValue) {
|
||||
pairs = append(pairs, userRole{User: u, Role: defaultRole})
|
||||
}
|
||||
|
||||
// From CSV
|
||||
if fromPath != "" {
|
||||
rows, err := common.ReadRowsFromCSV(fromPath, []string{"user_id", "role"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
role := row["role"]
|
||||
if role == "" {
|
||||
role = defaultRole
|
||||
}
|
||||
pairs = append(pairs, userRole{User: row["user_id"], Role: role})
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by user
|
||||
seen := map[string]bool{}
|
||||
deduped := make([]userRole, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
if seen[p.User] {
|
||||
continue
|
||||
}
|
||||
seen[p.User] = true
|
||||
deduped = append(deduped, p)
|
||||
}
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// collectUsers 收集用户名(仅用于 batch-remove,不需要角色信息)
|
||||
func collectUsers(usersValue, fromPath string) ([]string, error) {
|
||||
users := common.ParseStringList(usersValue)
|
||||
|
||||
if fromPath != "" {
|
||||
csvUsers, err := common.ReadColumnFromCSV(fromPath, []string{"user_id"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, csvUsers...)
|
||||
}
|
||||
|
||||
return common.DedupeStrings(users), nil
|
||||
}
|
||||
|
||||
// roleForUser 在 userRole 列表中查找指定用户的角色(线性查找)
|
||||
func roleForUser(pairs []userRole, user string) string {
|
||||
for _, p := range pairs {
|
||||
if p.User == user {
|
||||
return p.Role
|
||||
}
|
||||
}
|
||||
return "write"
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return append([]*common.Shortcut{
|
||||
// +list:列出仓库的协作者
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库协作者",
|
||||
Long: `List repository collaborators.
|
||||
|
||||
Shows all users who have access to the repository along with their
|
||||
username, role, and permission level.`,
|
||||
Example: ` # List collaborators of the current repository
|
||||
gitlink collaborator +list`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add:向仓库添加一名协作者
|
||||
{
|
||||
Name: "add",
|
||||
Description: "添加仓库协作者",
|
||||
Long: `Add a collaborator to the repository.
|
||||
|
||||
Invites a user to collaborate on the repository with the specified role.
|
||||
Available roles: admin, write (default), read.`,
|
||||
Example: ` # Add a collaborator with write access
|
||||
gitlink collaborator +add -u zhangsan -r write
|
||||
|
||||
# Add a collaborator with admin access
|
||||
gitlink collaborator +add -u zhangsan -r admin`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username to add", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Role: admin or write or read", Default: "write"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role := ctx.Arg("role")
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove:从仓库移除一名协作者
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "移除仓库协作者",
|
||||
Long: `Remove a collaborator from the repository.
|
||||
|
||||
Revokes the user's access to the repository.`,
|
||||
Example: ` # Remove a collaborator
|
||||
gitlink collaborator +remove -u zhangsan`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
},
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +change-role:修改协作者的角色
|
||||
{
|
||||
Name: "change-role",
|
||||
Description: "修改协作者角色",
|
||||
Long: `Change a collaborator's role.
|
||||
|
||||
Updates the permission level of an existing collaborator.
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Change a collaborator to admin role
|
||||
gitlink collaborator +change-role -u zhangsan -r admin
|
||||
|
||||
# Change a collaborator to read-only access
|
||||
gitlink collaborator +change-role -u zhangsan -r read`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "New role: admin or write or read", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := ctx.RequireArg("role")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// forgeplus change_role expects top-level {user_id: <int>, role: "Manager"}.
|
||||
// Resolve the username to its numeric id via the collaborators list.
|
||||
userID, err := resolveUserID(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": mapRole(role),
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
},
|
||||
// +batch-add:批量添加多名协作者
|
||||
newBatchAddShortcut(),
|
||||
// +batch-remove:批量移除多名协作者
|
||||
newBatchRemoveShortcut(),
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
newBatchRoleShortcut(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// mapRole maps friendly role names (admin/write/read) to forgeplus's role enum
|
||||
// (Manager/Developer/Reporter). Values already in enum form pass through
|
||||
// unchanged; matching is case-insensitive.
|
||||
func mapRole(r string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(r)) {
|
||||
case "admin", "manager":
|
||||
return "Manager"
|
||||
case "write", "developer":
|
||||
return "Developer"
|
||||
case "read", "reporter":
|
||||
return "Reporter"
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
// findUserIDByLogin scans the members list returned by GET /collaborators and
|
||||
// returns the numeric user_id for the given login. forgeplus's change_role
|
||||
// endpoint requires an integer id, but the CLI accepts a username (--user), so
|
||||
// the id must be resolved first.
|
||||
func findUserIDByLogin(members []interface{}, login string) (int, bool) {
|
||||
for _, m := range members {
|
||||
mm, ok := m.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
l, _ := mm["login"].(string)
|
||||
if l != login {
|
||||
continue
|
||||
}
|
||||
switch id := mm["id"].(type) {
|
||||
case float64: // JSON numbers decode to float64
|
||||
return int(id), true
|
||||
case float32:
|
||||
return int(id), true
|
||||
case int:
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// resolveUserID looks up a collaborator's numeric id by login name via the
|
||||
// collaborators list API. Required because change_role expects an integer id.
|
||||
func resolveUserID(ctx *common.RuntimeContext, login string) (int, error) {
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("cannot read collaborators list for %s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
members, _ := data["members"].([]interface{})
|
||||
id, found := findUserIDByLogin(members, login)
|
||||
if !found {
|
||||
return 0, fmt.Errorf("collaborator %q not found in %s/%s", login, ctx.Owner, ctx.Repo)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package collaborator
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMapRole 验证友好角色名映射到 forgeplus 的 Manager/Developer/Reporter,
|
||||
// 且直接传入枚举值或大小写不同也能正确处理。
|
||||
func TestMapRole(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"admin", "Manager"},
|
||||
{"write", "Developer"},
|
||||
{"read", "Reporter"},
|
||||
{"Manager", "Manager"},
|
||||
{"ADMIN", "Manager"},
|
||||
{"developer", "Developer"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mapRole(c.in); got != c.want {
|
||||
t.Errorf("mapRole(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindUserIDByLogin 验证从协作者列表按 login 解析数字 user_id。
|
||||
// change_role 接口的 user_id 要整数 ID,而 CLI --user 收的是用户名。
|
||||
func TestFindUserIDByLogin(t *testing.T) {
|
||||
members := []interface{}{
|
||||
map[string]interface{}{"login": "z2_cc", "id": float64(148913)},
|
||||
map[string]interface{}{"login": "wqer", "id": float64(148900)},
|
||||
}
|
||||
id, ok := findUserIDByLogin(members, "wqer")
|
||||
if !ok {
|
||||
t.Fatal("expected to find wqer")
|
||||
}
|
||||
if id != 148900 {
|
||||
t.Errorf("wqer id = %d, want 148900", id)
|
||||
}
|
||||
if _, ok := findUserIDByLogin(members, "ghost"); ok {
|
||||
t.Error("expected not found for missing user")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库提交记录
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库提交记录",
|
||||
Long: `List repository commits.
|
||||
|
||||
Shows commit SHA, author, message, and date.
|
||||
Use --page and --limit for pagination.
|
||||
Filter by path, date range, or branch with respective flags.`,
|
||||
Example: ` # List recent commits
|
||||
gitlink commit +list
|
||||
|
||||
# List commits on a specific branch
|
||||
gitlink commit +list --sha develop
|
||||
|
||||
# List commits touching a specific file
|
||||
gitlink commit +list --path src/main.go`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch or SHA to list commits from"},
|
||||
{Name: "path", Short: "p", Usage: "Filter commits by file path"},
|
||||
{Name: "page", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "since", Usage: "Only commits after this date (ISO 8601)"},
|
||||
{Name: "until", Usage: "Only commits before this date (ISO 8601)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看提交详情(含变更文件)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看提交详情(含变更文件)",
|
||||
Long: `View commit details including changed files.
|
||||
|
||||
Shows the full commit information and the list of files changed
|
||||
in the specified commit.`,
|
||||
Example: ` # View a commit
|
||||
gitlink commit +view --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +diff:查看提交的 diff 内容
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "查看提交的 diff 内容",
|
||||
Long: `View the diff of a commit.
|
||||
|
||||
Returns the patch/diff content for the specified commit,
|
||||
showing exact line-level additions and deletions.`,
|
||||
Example: ` # View the diff of a commit
|
||||
gitlink commit +diff --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blame:获取文件的逐行提交追溯信息
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "获取文件的逐行提交追溯信息",
|
||||
Long: `Get blame information for a file.
|
||||
|
||||
Shows which commit and author last modified each line of a file.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # Blame a file on the default branch
|
||||
gitlink commit +blame --path src/main.go
|
||||
|
||||
# Blame a file at a specific commit
|
||||
gitlink commit +blame --path src/main.go --sha abc123def`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA or branch"},
|
||||
{Name: "page", 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
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +compare:比较两个引用(分支、标签或 SHA)
|
||||
{
|
||||
Name: "compare",
|
||||
Description: "比较两个引用(分支、标签或 SHA)",
|
||||
Long: `Compare two refs (branches, tags, or SHAs).
|
||||
|
||||
Returns the commits and file changes between a base and head ref.
|
||||
Both --base and --head are required.`,
|
||||
Example: ` # Compare two branches
|
||||
gitlink commit +compare --base main --head develop
|
||||
|
||||
# Compare two commit SHAs
|
||||
gitlink commit +compare --base abc123 --head def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "head", Short: "H", Usage: "Head ref", Required: true},
|
||||
{Name: "base", Short: "B", Usage: "Base ref", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
head, err := ctx.RequireArg("head")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base, err := ctx.RequireArg("base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/compare/%s...%s", ctx.Owner, ctx.Repo, base, head), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Result structures 批量操作输出结构 ---
|
||||
|
||||
// BatchResult 记录批量操作中单个项目的处理结果
|
||||
type BatchResult struct {
|
||||
Item string `json:"item" yaml:"item"` // 项目标识(issue/PR 编号、分支名、用户名等)
|
||||
Action string `json:"action" yaml:"action"` // 操作类型(如 "close"、"delete"、"add")
|
||||
Status string `json:"status" yaml:"status"` // 状态:"planned"(dry-run)、"ok"(成功)、"failed"(失败)
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 失败原因
|
||||
}
|
||||
|
||||
// BatchSummary 是所有批量命令的统一输出格式
|
||||
// 无论操作哪种资源,输出结构都一致,方便脚本处理
|
||||
type BatchSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"` // 仓库路径 owner/repo
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"` // 是否为预览模式
|
||||
Total int `json:"total" yaml:"total"` // 总数
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"` // 成功数
|
||||
Failed int `json:"failed" yaml:"failed"` // 失败数
|
||||
Results []BatchResult `json:"results" yaml:"results"` // 每个项目的详细结果
|
||||
}
|
||||
|
||||
// --- Batch processing loop 核心处理循环 ---
|
||||
|
||||
// ProcessBatch 是批处理的"发动机"——对每项执行操作,支持 dry-run,错误不中断
|
||||
// items: 待处理项列表(如 ["1","2","3"])
|
||||
// dryRun: true 时只展示计划,不执行任何 API 调用
|
||||
// action: 操作名称(如 "close"、"delete"),会填入 BatchResult.Action
|
||||
// fn: 业务逻辑函数(回调/控制反转模式),调用方定义"对单个项做什么操作"
|
||||
// 返回值: BatchSummary 包含每项详细结果
|
||||
|
||||
func ProcessBatch(items []string, dryRun bool, action string, fn func(string) error) *BatchSummary {
|
||||
summary := &BatchSummary{
|
||||
DryRun: dryRun,
|
||||
Total: len(items),
|
||||
Results: make([]BatchResult, 0, len(items)),
|
||||
}
|
||||
// 逐项处理:dry-run 模式只标记不执行;否则调用 fn 执行实际操作
|
||||
for _, item := range items {
|
||||
r := BatchResult{Item: item, Action: action}
|
||||
if dryRun {
|
||||
r.Status = "planned" // 预览模式:不调 API,仅标记
|
||||
summary.Succeeded++
|
||||
} else if err := fn(item); err != nil {
|
||||
r.Status = "failed" // 执行失败:记录错误,继续下一项
|
||||
r.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
r.Status = "ok" // 执行成功
|
||||
summary.Succeeded++
|
||||
}
|
||||
// 单项失败不中断循环——所有项都会处理完,结果全在 summary 中
|
||||
summary.Results = append(summary.Results, r)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
// --- Input collection ---
|
||||
|
||||
// CollectNumbers 收集整数标识符(issue/PR 编号),支持两种输入方式合并去重
|
||||
// numbersValue: 来自 --numbers 参数(逗号分隔,如 "1,2,3")
|
||||
// csvPath: 来自 --from 参数(CSV 文件路径)
|
||||
// 两种方式可组合使用:CollectNumbers("1,2", "data.csv") → ["1","2","5","6"]
|
||||
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := ParseNumberList(numbersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return numbers, nil
|
||||
}
|
||||
csvNumbers, err := ReadColumnFromCSV(csvPath, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DedupeStrings(append(numbers, csvNumbers...)), nil
|
||||
}
|
||||
|
||||
// CollectStrings 收集字符串标识符(分支名、用户名等),支持 --names 和 --from 文件合并去重
|
||||
// 与 CollectNumbers 不同,此类标识符不需要是整数
|
||||
// 文件格式:每行一个名称(纯文本,不是 CSV)
|
||||
func CollectStrings(namesValue, filePath string) ([]string, error) {
|
||||
names := ParseStringList(namesValue)
|
||||
if filePath == "" {
|
||||
return names, nil
|
||||
}
|
||||
fileNames, err := ReadLinesFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DedupeStrings(append(names, fileNames...)), nil
|
||||
}
|
||||
|
||||
// --- Parsing helpers 解析辅助函数 ---
|
||||
|
||||
// ParseNumberList 解析逗号分隔数字列表 → 校验每个值都是整数 + 去重
|
||||
// 输入 "7,8,9" → ["7","8","9"];空字符串 → nil(合法空值)
|
||||
// 输入 "7,abc" → 报错 "invalid number \"abc\": must be an integer"
|
||||
func ParseNumberList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NormalizeIntStrings(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
// ParseStringList 解析逗号分隔字符串列表,自动去除空格和空值
|
||||
// 输入 "branch1, branch2, " → ["branch1","branch2"]
|
||||
// 注意:此函数不做整数校验,适用于分支名/用户名等非数字标识符
|
||||
func ParseStringList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- CSV / file reading ---
|
||||
|
||||
// ReadColumnFromCSV 从 CSV 文件中读取单列数据(支持表头自动检测)
|
||||
// columnNames: 可接受的列名列表,按优先级排序
|
||||
// 列名检测逻辑:依次匹配 number / issue_number / project_issues_index
|
||||
// 都不匹配则默认使用第一列;有表头从第2行读,无表头从第1行读
|
||||
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
column := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
for _, name := range columnNames {
|
||||
if strings.EqualFold(strings.TrimSpace(cell), name) {
|
||||
column = i
|
||||
startRow = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if column >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if column < 0 {
|
||||
column = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if column >= len(record) {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(record[column])
|
||||
if v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ReadRowsFromCSV 从 CSV 文件读取多列数据,返回 [{col:val, ...}, ...]
|
||||
// 适用于需要同时读取多列的批量操作,如 collaborator 需要 user_id + role 两列
|
||||
// requiredCols: 需要的列名列表,函数会校验这些列是否都存在,不存在则报错
|
||||
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 步骤1:从表头行构建列名→列索引的映射表
|
||||
header := records[0]
|
||||
colIndex := make(map[string]int)
|
||||
for i, cell := range header {
|
||||
normalized := strings.ToLower(strings.TrimSpace(cell))
|
||||
for _, req := range requiredCols {
|
||||
if normalized == req {
|
||||
colIndex[req] = i // 记录该列在第几列
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤2:校验所有必需的列是否都在表头中找到,缺列直接报错
|
||||
for _, req := range requiredCols {
|
||||
if _, ok := colIndex[req]; !ok {
|
||||
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:逐行读取数据,按列名取值
|
||||
rows := make([]map[string]string, 0, len(records)-1)
|
||||
for _, record := range records[1:] { // 跳过表头行
|
||||
row := make(map[string]string, len(requiredCols))
|
||||
for _, req := range requiredCols {
|
||||
idx := colIndex[req]
|
||||
if idx < len(record) {
|
||||
row[req] = strings.TrimSpace(record[idx])
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ReadLinesFromFile 从纯文本文件逐行读取(非CSV),自动忽略空行
|
||||
// 用于支持 --from 参数传入分支名、用户名列表(每行一个)
|
||||
func ReadLinesFromFile(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Validation & deduplication ---
|
||||
|
||||
// NormalizeIntStrings 校验每个值都是合法整数 + 去重,批处理的输入"安检"
|
||||
// 输入 ["7","8","8","abc"] → 报错("abc" 不是整数)
|
||||
// 输入 ["7","8","8","9"] → ["7","8","9"](重复的 "8" 被去掉)
|
||||
func NormalizeIntStrings(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue // 忽略空字符串
|
||||
}
|
||||
// 核心校验:strconv.ParseInt 尝试将字符串解析为 64 位整数
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: must be an integer", v)
|
||||
}
|
||||
if seen[v] {
|
||||
continue // 重复值跳过
|
||||
}
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DedupeStrings 字符串去重(保持原始顺序,保留第一个出现的)
|
||||
func DedupeStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
// ParseBool 解析布尔字符串,用于处理 --prerelease 等布尔参数
|
||||
func ParseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
// ConfirmAction 在破坏性操作前弹出确认提示,防止误操作
|
||||
// 检查流程: ① 全局 --yes 参数(用于自动化脚本,跳过确认)
|
||||
// ② 否则打印 "Are you sure... [y/N]:" 等待用户输入
|
||||
// ③ 只有 y/yes 才继续,其他全部取消
|
||||
// 覆盖 4 个单操作(repo/branch/release delete, ci stop)+ 4 个批量命令
|
||||
func ConfirmAction(action string) error {
|
||||
// 第①步:检查全局 --yes 标志,为 true 则跳过确认
|
||||
if cmdutil.AutoConfirm {
|
||||
return nil
|
||||
}
|
||||
// 第②步:打印提示并读取键盘输入
|
||||
fmt.Printf("Are you sure you want to %s? [y/N]: ", action)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("operation cancelled")
|
||||
}
|
||||
// 第③步:规范化输入,只接受 y/yes
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
return fmt.Errorf("operation cancelled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -34,6 +34,12 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
|||
return s.Run(ctx)
|
||||
},
|
||||
}
|
||||
if s.Long != "" {
|
||||
cmd.Long = s.Long
|
||||
}
|
||||
if s.Example != "" {
|
||||
cmd.Example = s.Example
|
||||
}
|
||||
|
||||
for _, f := range s.Flags {
|
||||
if f.Bool {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
type Shortcut struct {
|
||||
Name string
|
||||
Description string
|
||||
Long string
|
||||
Example string
|
||||
Flags []Flag
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
|
@ -117,3 +119,29 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
|||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// [P1 Bug#2 修复] 原代码在 branch +create 和 release +create 中硬编码 "master" 作为默认分支
|
||||
// 当仓库默认分支是 "main" 时会出错。此函数动态查询仓库的默认分支,失败时回退到 "master"
|
||||
// 设计模式:动态查询 + 静态兜底(graceful degradation)
|
||||
func GetDefaultBranch(ctx *RuntimeContext) string {
|
||||
if ctx.Owner == "" || ctx.Repo == "" {
|
||||
return "master" // owner/repo 未设置,直接回退
|
||||
}
|
||||
// 调用 GET /{owner}/{repo} API 获取仓库元信息
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return "master" // API 调用失败(网络错误等),回退
|
||||
}
|
||||
// 将返回数据断言为 map[string]interface{}(JSON 对象)
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "master" // 返回格式异常,回退
|
||||
}
|
||||
// 依次尝试两种可能的字段名(GitLink API 有时返回 snake_case,有时 camelCase)
|
||||
for _, key := range []string{"default_branch", "defaultBranch"} {
|
||||
if db, ok := data[key].(string); ok && db != "" {
|
||||
return db // 成功获取默认分支名
|
||||
}
|
||||
}
|
||||
return "master" // 字段不存在或为空,回退
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package dataset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +create:在仓库中创建数据集
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中创建数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Dataset title", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Dataset description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的数据集
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "New dataset title"},
|
||||
{Name: "description", Short: "d", Usage: "New dataset description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title := ctx.Arg("title")
|
||||
description := ctx.Arg("description")
|
||||
if title == "" && description == "" {
|
||||
return fmt.Errorf("at least one of --title or --description is required")
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
if description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看仓库的数据集详情
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看仓库的数据集详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +list:列出项目数据集
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出项目数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo)
|
||||
q := buildQuery(ctx)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildQuery(ctx *common.RuntimeContext) map[string][]string {
|
||||
q := map[string][]string{}
|
||||
if v := ctx.Arg("keyword"); v != "" {
|
||||
q["keyword"] = []string{v}
|
||||
}
|
||||
if v := ctx.Arg("page"); v != "" {
|
||||
q["page"] = []string{v}
|
||||
}
|
||||
if v := ctx.Arg("limit"); v != "" {
|
||||
q["limit"] = []string{v}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +get:获取仓库文件内容
|
||||
{
|
||||
Name: "get",
|
||||
Description: "获取仓库文件内容",
|
||||
Long: `Get file contents from the repository.
|
||||
|
||||
Retrieves the content and metadata of a file at the specified path.
|
||||
Optionally specify a branch, tag, or commit SHA with --ref.`,
|
||||
Example: ` # Get file contents on the default branch
|
||||
gitlink file +get --path README.md
|
||||
|
||||
# Get file contents on a specific branch
|
||||
gitlink file +get --path src/main.go --ref develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// GitLink has no "get raw file content by path" endpoint. The
|
||||
// legacy /files endpoint returned the whole repo listing, and
|
||||
// /v1/contents/{path} falls through to the web router (HTML).
|
||||
// Use /entries?filepath=<dir>&ref= to fetch the file's metadata
|
||||
// (name/path/sha/size); to read raw content, follow up with
|
||||
// `file +blob --sha <sha>`.
|
||||
dir := pathDir(filePath)
|
||||
base := pathBase(filePath)
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo), buildQuery(map[string]string{
|
||||
"filepath": dir,
|
||||
"ref": ctx.Arg("ref"),
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pick the requested file out of the directory listing.
|
||||
if entry := findEntryByName(env, base); entry != nil {
|
||||
return ctx.OutputData(entry)
|
||||
}
|
||||
return fmt.Errorf("文件 %q 不存在", filePath)
|
||||
},
|
||||
},
|
||||
// +readme:获取仓库 README
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "获取仓库 README",
|
||||
Long: `Get the repository README.
|
||||
|
||||
Fetches the README file of the current repository.`,
|
||||
Example: ` # Show the repository README
|
||||
gitlink file +readme`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/readme", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在仓库中新建文件
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中新建文件",
|
||||
Long: `Create a new file in the repository.
|
||||
|
||||
Creates a file at the given path with the provided content.
|
||||
The content is automatically base64-encoded before submission.
|
||||
A commit message is required.`,
|
||||
Example: ` # Create a new file
|
||||
gitlink file +create --path docs/guide.md --content "# Guide" --message "add guide"
|
||||
|
||||
# Create a file on a specific branch
|
||||
gitlink file +create --path config.yaml --content "key: value" --message "add config" --branch develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (plain text, will be base64-encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The API requires either branch or new_branch; without one it
|
||||
// returns "branch和new_branch必须存在一个".
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch(已有分支)或 --new-branch(新建分支);目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/create_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的已有文件
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的已有文件",
|
||||
Long: `Update an existing file in the repository.
|
||||
|
||||
Replaces the content of a file at the given path.
|
||||
The new content is automatically base64-encoded before submission.
|
||||
Provide --sha to detect conflicting mid-air edits.`,
|
||||
Example: ` # Update a file
|
||||
gitlink file +update --path docs/guide.md --content "# Updated Guide" --message "update guide"
|
||||
|
||||
# Update with conflict detection
|
||||
gitlink file +update --path config.yaml --content "key: new" --message "update config" --sha abc123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "New file content (plain text, will be base64-encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
{Name: "sha", Usage: "Original file SHA (required by the API for conflict detection)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// API requires sha (and branch or new_branch).
|
||||
sha := ctx.Arg("sha")
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if sha == "" {
|
||||
return fmt.Errorf("必须指定 --sha(原文件 SHA);可用 file +entries 获取")
|
||||
}
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch 或 --new-branch;目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
"sha": sha,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/update_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除仓库中的文件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库中的文件",
|
||||
Long: `Delete a file from the repository.
|
||||
|
||||
Removes the file at the given path from the repository.
|
||||
A commit message is required.
|
||||
Provide --sha to detect conflicting mid-air edits.`,
|
||||
Example: ` # Delete a file
|
||||
gitlink file +delete --path docs/old.md --message "remove old docs"
|
||||
|
||||
# Delete with conflict detection
|
||||
gitlink file +delete --path config.yaml --message "remove config" --sha abc123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path to delete", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
{Name: "sha", Usage: "File SHA (required by the API for conflict detection)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// API requires sha (and branch or new_branch).
|
||||
sha := ctx.Arg("sha")
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if sha == "" {
|
||||
return fmt.Errorf("必须指定 --sha(文件 SHA);可用 file +entries 获取")
|
||||
}
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch 或 --new-branch;目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
"sha": sha,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/delete_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +tree:获取指定 SHA 的 git 树对象(顶层目录)
|
||||
// 注意:GitLink 的 /git/trees/:sha 端点不支持 recursive 参数(实测无论传
|
||||
// true/false 都报"Recursive不包含于列表中"),故只返回顶层条目。
|
||||
// 要遍历子目录,请用 file +entries --path <子目录> 逐层展开。
|
||||
{
|
||||
Name: "tree",
|
||||
Description: "获取指定 SHA 的 git 树对象(顶层目录)",
|
||||
Long: `Get the top-level git tree for a given SHA (branch, tag, or commit).
|
||||
|
||||
Returns the directory/file listing at the repository root for that ref.
|
||||
GitLink's /git/trees endpoint does not support recursive listing; to
|
||||
descend into a subdirectory use 'file +entries --path <dir>'.`,
|
||||
Example: ` # List top-level entries on master
|
||||
gitlink file +tree --sha master
|
||||
|
||||
# List entries at a specific commit
|
||||
gitlink file +tree --sha 7947d4dfd67bbf64265dd89465dfe9ce6a92bf83`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha)
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blob:根据 SHA 获取 git blob 内容
|
||||
{
|
||||
Name: "blob",
|
||||
Description: "根据 SHA 获取 git blob 内容",
|
||||
Long: `Get a git blob by SHA.
|
||||
|
||||
Returns the raw blob content for the specified SHA.`,
|
||||
Example: ` # Get a git blob
|
||||
gitlink file +blob --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Blob SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/git/blobs/%s", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch:在单次提交中批量创建或更新多个文件
|
||||
{
|
||||
Name: "batch",
|
||||
Description: "在单次提交中批量创建或更新多个文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "files", Short: "f", Usage: "JSON array of file objects", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filesStr, err := ctx.RequireArg("files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var files []interface{}
|
||||
if err := json.Unmarshal([]byte(filesStr), &files); err != nil {
|
||||
return fmt.Errorf("invalid JSON for --files: %w", err)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"files": files,
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/contents/batch", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +entries:获取仓库目录列表
|
||||
{
|
||||
Name: "entries",
|
||||
Description: "获取仓库目录列表",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"path": ctx.Arg("path"),
|
||||
"ref": ctx.Arg("ref"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sub-entries:获取仓库子目录列表
|
||||
{
|
||||
Name: "sub-entries",
|
||||
Description: "获取仓库子目录列表",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/%s/%s/sub_entries", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"path": ctx.Arg("path"),
|
||||
"ref": ctx.Arg("ref"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// 注:原 +replace 命令已移除。其端点 /replace_file 返回"接口方法异常"
|
||||
//(端点废弃),且功能与 +create(新文件)/ +update(已有文件)完全重复。
|
||||
// 如需替换文件内容,请用 file +update。
|
||||
}
|
||||
}
|
||||
|
||||
func buildQuery(params map[string]string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for k, v := range params {
|
||||
if v != "" {
|
||||
if v == "true" {
|
||||
query[k] = []string{"true"}
|
||||
} else {
|
||||
query[k] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// pathDir returns the directory portion of a file path ("a/b/c.md" -> "a/b",
|
||||
// "c.md" -> ""). Unlike path.Dir it never returns "." for a bare filename.
|
||||
func pathDir(p string) string {
|
||||
dir := path.Dir(p)
|
||||
if dir == "." || dir == "/" {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// pathBase returns the final element of a path ("a/b/c.md" -> "c.md").
|
||||
func pathBase(p string) string {
|
||||
return path.Base(p)
|
||||
}
|
||||
|
||||
// findEntryByName scans an /entries envelope for an entry whose name matches.
|
||||
// Used by file +get to extract a single file's metadata from a directory listing.
|
||||
func findEntryByName(env *output.Envelope, name string) map[string]interface{} {
|
||||
if env == nil || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
entries, ok := data["entries"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
entry, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, _ := entry["name"].(string); n == name {
|
||||
return entry
|
||||
}
|
||||
// Some payloads key the filename under "path".
|
||||
if p, _ := entry["path"].(string); p != "" && pathBase(p) == name {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep strings import used (pathBase fallback path uses path, but strings is
|
||||
// referenced implicitly nowhere — guard against unused import if helpers shrink).
|
||||
var _ = strings.TrimSpace
|
||||
|
|
@ -1,37 +1,95 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const closedIssueStatusID = 5
|
||||
|
||||
type batchCloseResult struct {
|
||||
Number string `json:"number" yaml:"number"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared: resolve database IDs from user-facing issue numbers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// resolveIssueDBIDs 将用户可见的项目编号映射为数据库主键 ID
|
||||
// 用户输入的是 issue #1、#2(项目级别编号),但 GitLink 批量 API 需要数据库 ID(如 143372)
|
||||
// 流程:GET /issues 获取全量列表 → 构建 project_issues_index → id 映射表 → 按用户输入查找
|
||||
// 仅 A 类命令(batch-update/batch-destroy)需要此映射,B 类命令逐个调 API 时直接用项目编号
|
||||
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", "200")
|
||||
q.Set("page", "1")
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch issues for ID mapping: %w", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected issues response format")
|
||||
}
|
||||
issues, ok := data["issues"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected issues list format")
|
||||
}
|
||||
|
||||
// 构建 project_issues_index → 数据库ID 的映射表
|
||||
// 例如: {"3": 143372, "4": 143373, "5": 143374}
|
||||
indexToID := make(map[string]interface{}, len(issues))
|
||||
for _, item := range issues {
|
||||
issue, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx, ok := issue["project_issues_index"] // 用户可见的编号(如 3)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, ok := issue["id"] // 数据库主键 ID(如 143372)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 统一转为字符串作为 key,确保数字类型匹配
|
||||
indexToID[fmt.Sprintf("%v", idx)] = id
|
||||
}
|
||||
|
||||
// 用户输入的编号 → 查找对应数据库 ID,找不到则报错
|
||||
dbIDs := make([]interface{}, 0, len(numbers))
|
||||
for _, n := range numbers {
|
||||
id, found := indexToID[n]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("issue #%s not found in repository", n)
|
||||
}
|
||||
dbIDs = append(dbIDs, id)
|
||||
}
|
||||
return dbIDs, nil
|
||||
}
|
||||
|
||||
type batchCloseSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Results []batchCloseResult `json:"results" yaml:"results"`
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按疑修编号或 CSV 文件批量关闭多个疑修
|
||||
Name: "batch-close",
|
||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||||
Description: "按疑修编号或 CSV 文件批量关闭疑修",
|
||||
Long: `Close multiple issues in a single operation.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --dry-run to preview which issues would be closed without making changes.
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Close issues 1, 2, and 3
|
||||
gitlink issue +batch-close --numbers 1,2,3
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-close --numbers 1,2,3 --dry-run
|
||||
|
||||
# Close issues listed in a CSV file
|
||||
gitlink issue +batch-close --from issues.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||
|
|
@ -41,12 +99,14 @@ func newBatchCloseShortcut() *common.Shortcut {
|
|||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 issue——PATCH /issues/{n},每个请求独立
|
||||
// 不需要 ID 映射,单 issue API 接受项目编号而非数据库主键
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -54,33 +114,11 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchCloseSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchCloseResult{Number: number, Action: "close"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := closeIssue(ctx, number); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "closed"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
|
||||
return closeIssue(ctx, number)
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
|
|
@ -92,121 +130,211 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
|
||||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||
// 先获取 issue 当前数据——PATCH API 要求提交 subject 和 description(不能为空)
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": closedIssueStatusID,
|
||||
"subject": current.Subject, // 保留原标题
|
||||
"description": current.Description, // 保留原描述
|
||||
"status_id": closedIssueStatusID, // 5 = closed
|
||||
}
|
||||
// PATCH /issues/{number} 只修改 status_id,保留其他字段
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("close issue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-update:使用原生批量接口一次性更新多个疑修(支持 --dry-run 预览)
|
||||
Name: "batch-update",
|
||||
Description: "通过原生批量接口批量更新多个疑修",
|
||||
Long: `Update multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
At least one update field (--state, --milestone, --assignee, --label, or --priority)
|
||||
must be specified. Use --dry-run to preview changes without applying them.`,
|
||||
Example: ` # Close multiple issues
|
||||
gitlink issue +batch-update --numbers 1,2,3 --state closed
|
||||
|
||||
# Set milestone for issues from a CSV file
|
||||
gitlink issue +batch-update --from issues.csv --milestone 5
|
||||
|
||||
# Assign issues to a user and add labels
|
||||
gitlink issue +batch-update --numbers 10,11,12 --assignee 3 --label 7,8
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-update --numbers 1,2,3 --priority 1 --dry-run`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "state", Short: "s", Usage: "Set state: open, closed, or numeric status_id"},
|
||||
{Name: "milestone", Short: "m", Usage: "Set milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Set assignee user ID"},
|
||||
{Name: "label", Short: "l", Usage: "Set label ID(s), comma-separated for multiple"},
|
||||
{Name: "priority", Short: "p", Usage: "Set priority ID"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 PATCH /issues/batch_update 请求操作多个 issue
|
||||
// 需要先通过 resolveIssueDBIDs 将项目编号转为数据库 ID
|
||||
func runBatchUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return numbers, nil
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
||||
state := ctx.Arg("state")
|
||||
milestone := ctx.Arg("milestone")
|
||||
assignee := ctx.Arg("assignee")
|
||||
label := ctx.Arg("label")
|
||||
priority := ctx.Arg("priority")
|
||||
|
||||
if state == "" && milestone == "" && assignee == "" && label == "" && priority == "" {
|
||||
return fmt.Errorf("at least one of --state, --milestone, --assignee, --label, or --priority is required")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return mergeIssueNumbers(numbers, csvNumbers), nil
|
||||
}
|
||||
|
||||
func parseIssueNumbers(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
body := map[string]interface{}{
|
||||
"ids": dbIDs,
|
||||
}
|
||||
if state != "" {
|
||||
statusID, err := normalizeIssueStatus(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
if milestone != "" {
|
||||
body["fixed_version_id"] = milestone
|
||||
}
|
||||
if assignee != "" {
|
||||
body["assigned_to_id"] = assignee
|
||||
}
|
||||
if label != "" {
|
||||
tagIDs := common.ParseStringList(label)
|
||||
body["issue_tag_ids"] = tagIDs
|
||||
}
|
||||
if priority != "" {
|
||||
body["priority_id"] = priority
|
||||
}
|
||||
return normalizeIssueNumbers(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if dryRun {
|
||||
plan := map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"dry_run": true,
|
||||
"issues": numbers,
|
||||
"updates": body,
|
||||
}
|
||||
return ctx.OutputData(plan)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
||||
return fmt.Errorf("batch update: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-destroy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchDestroyShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-destroy:使用原生批量接口一次性永久删除多个疑修(支持 --dry-run 与确认提示)
|
||||
Name: "batch-destroy",
|
||||
Description: "通过原生批量接口批量删除多个疑修",
|
||||
Long: `Permanently delete multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
This action is irreversible. Use --dry-run to preview which issues would be deleted.
|
||||
You will be prompted for confirmation unless --yes is set.`,
|
||||
Example: ` # Delete issues 7, 8, and 9
|
||||
gitlink issue +batch-destroy --numbers 7,8,9
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-destroy --numbers 7,8,9 --dry-run
|
||||
|
||||
# Delete issues listed in a CSV file
|
||||
gitlink issue +batch-destroy --from old_issues.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview deletions without executing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDestroy,
|
||||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 DELETE /issues/batch_destroy 删除多个 issue(不可逆)
|
||||
// 执行前调用 ConfirmAction 弹确认框
|
||||
func runBatchDestroy(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
numberColumn := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "number", "issue_number", "project_issues_index":
|
||||
numberColumn = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if numberColumn == -1 {
|
||||
numberColumn = 0
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if numberColumn >= len(record) {
|
||||
continue
|
||||
if dryRun {
|
||||
plan := map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"dry_run": true,
|
||||
"issues": numbers,
|
||||
"database_ids": dbIDs,
|
||||
}
|
||||
values = append(values, record[numberColumn])
|
||||
return ctx.OutputData(plan)
|
||||
}
|
||||
return normalizeIssueNumbers(values)
|
||||
}
|
||||
|
||||
func normalizeIssueNumbers(values []string) ([]string, error) {
|
||||
numbers := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
number := strings.TrimSpace(value)
|
||||
if number == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
||||
}
|
||||
if seen[number] {
|
||||
continue
|
||||
}
|
||||
seen[number] = true
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers, nil
|
||||
}
|
||||
|
||||
func mergeIssueNumbers(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, numbers := range values {
|
||||
for _, number := range numbers {
|
||||
if seen[number] {
|
||||
continue
|
||||
}
|
||||
seen[number] = true
|
||||
merged = append(merged, number)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
|
||||
if err := common.ConfirmAction(fmt.Sprintf("batch delete %d issue(s)", len(numbers))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"ids": dbIDs,
|
||||
}
|
||||
// Use CallAPIWithQuery for DELETE since the body needs to be sent.
|
||||
// The client.Do method sends body for any method, so CallAPI works.
|
||||
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch destroy: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,79 +5,82 @@ import (
|
|||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseIssueNumbers(t *testing.T) {
|
||||
got, err := parseIssueNumbers("1, 2,2, 3")
|
||||
func TestParseNumberList(t *testing.T) {
|
||||
got, err := common.ParseNumberList("1, 2,2, 3")
|
||||
if err != nil {
|
||||
t.Fatalf("parseIssueNumbers returned error: %v", err)
|
||||
t.Fatalf("ParseNumberList returned error: %v", err)
|
||||
}
|
||||
want := []string{"1", "2", "3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseIssueNumbers() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ParseNumberList() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) {
|
||||
if _, err := parseIssueNumbers("1,abc"); err == nil {
|
||||
t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number")
|
||||
func TestParseNumberListRejectsInvalidNumber(t *testing.T) {
|
||||
if _, err := common.ParseNumberList("1,abc"); err == nil {
|
||||
t.Fatal("ParseNumberList() expected an error for a non-integer issue number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"12", "13"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"12", "13"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
|
||||
path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"21", "22"}
|
||||
// ReadColumnFromCSV does not deduplicate; CollectNumbers handles that.
|
||||
want := []string{"21", "22", "21"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) {
|
||||
func TestCollectNumbersMergesCLIAndCSV(t *testing.T) {
|
||||
path := writeTempCSV(t, "number\n2\n3\n")
|
||||
got, err := collectIssueNumbers("1,2", path)
|
||||
got, err := common.CollectNumbers("1,2", path)
|
||||
if err != nil {
|
||||
t.Fatalf("collectIssueNumbers returned error: %v", err)
|
||||
t.Fatalf("CollectNumbers returned error: %v", err)
|
||||
}
|
||||
want := []string{"1", "2", "3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want)
|
||||
t.Fatalf("CollectNumbers() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBool(t *testing.T) {
|
||||
if !parseBool("true") {
|
||||
t.Fatal("parseBool(true) = false, want true")
|
||||
if !common.ParseBool("true") {
|
||||
t.Fatal("ParseBool(true) = false, want true")
|
||||
}
|
||||
if parseBool("") {
|
||||
t.Fatal("parseBool(empty) = true, want false")
|
||||
if common.ParseBool("") {
|
||||
t.Fatal("ParseBool(empty) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -22,13 +23,47 @@ type existingIssue struct {
|
|||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDestroyShortcut(),
|
||||
// +list:列出当前仓库的疑修(支持按状态/里程碑/标签筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
Description: "列出疑修",
|
||||
Long: `List issues in the current repository.
|
||||
|
||||
Shows issue number, title, status, and other details.
|
||||
Use --state to filter by status, --milestone, --assignee, --label,
|
||||
--keyword for additional filtering, and --page and --limit for pagination.`,
|
||||
Example: ` # List open issues (default)
|
||||
gitlink issue +list
|
||||
|
||||
# List closed issues
|
||||
gitlink issue +list --state closed
|
||||
|
||||
# Show 50 issues per page
|
||||
gitlink issue +list --limit 50
|
||||
|
||||
# Filter by milestone and assignee
|
||||
gitlink issue +list --milestone 3 --assignee 5
|
||||
|
||||
# Search by keyword
|
||||
gitlink issue +list -k "login bug"
|
||||
|
||||
# Sort by created date
|
||||
gitlink issue +list --sort created_on
|
||||
|
||||
# Sort by updated date descending
|
||||
gitlink issue +list --sort updated_on --sort-direction desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Filter by assignee user ID"},
|
||||
{Name: "label", Usage: "Filter by label ID"},
|
||||
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -40,16 +75,50 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
// [P2 参数补全] 以下 6 个查询参数原 CLI 未暴露,但 GitLink API 支持
|
||||
// CLI 参数名 → API 字段名的映射(两者经常不一致)
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("fixed_version_id", m) // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assigned_to_id", a) // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
q.Set("issue_tag_id", l) // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索(名称一致,无需映射)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段(如 created_on, updated_on)
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向 asc/desc(实测 API 用 sort+sort_direction,不是 sort_by)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeIssueListIDs(env)
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前仓库创建新疑修(需 --title,可附带描述/指派人/里程碑/标签)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new issue",
|
||||
Description: "创建新疑修",
|
||||
Long: `Create a new issue in the current repository.
|
||||
|
||||
Requires a title (--title). You may optionally set a description,
|
||||
assignee, milestone, and label at creation time.`,
|
||||
Example: ` # Create an issue with just a title
|
||||
gitlink issue +create --title "Fix login bug"
|
||||
|
||||
# Create with description and assignee
|
||||
gitlink issue +create --title "Fix login bug" --body "Steps to reproduce..." --assignee zhangsan
|
||||
|
||||
# Create with milestone and label
|
||||
gitlink issue +create --title "New feature" --milestone 3 --label 5`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
||||
|
|
@ -75,10 +144,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
body["description"] = desc
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
// [P1 Bug#1 修复] 原代码只在 Flags 中定义了 label 参数,Run 函数里没有取值写入请求体
|
||||
// 导致用户传 --label 5 不报错,但创建的 issue 没有标签
|
||||
// 修复:从 ctx.Arg("label") 取值,映射到 API 字段名 "issue_tag_id"
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
|
|
@ -87,9 +162,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定疑修的详情(标题/描述/状态/指派人等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View issue details",
|
||||
Description: "查看疑修详情",
|
||||
Long: `View detailed information about a specific issue.
|
||||
|
||||
Displays the issue title, description, status, assignee,
|
||||
and other metadata. Use the issue number as shown in the web URL.`,
|
||||
Example: ` # View issue #42
|
||||
gitlink issue +view --number 42
|
||||
|
||||
# Short form
|
||||
gitlink issue +view -n 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
|
|
@ -108,11 +193,26 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭疑修(保留标题和描述,可选附上说明性评论)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "Close an issue",
|
||||
Description: "关闭疑修",
|
||||
Long: `Close an existing issue by setting its status to closed.
|
||||
|
||||
The issue number is the one shown in the web URL (project-level index).
|
||||
This preserves the existing title and description.
|
||||
Use --comment to leave an explanation when closing.`,
|
||||
Example: ` # Close issue #42
|
||||
gitlink issue +close --number 42
|
||||
|
||||
# Short form
|
||||
gitlink issue +close -n 42
|
||||
|
||||
# Close with a comment
|
||||
gitlink issue +close -n 42 --comment "Fixed in commit abc123"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "comment", Short: "c", Usage: "Comment to add when closing"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -136,17 +236,51 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] 关闭(PATCH)和评论(POST journals)是两个独立 API,代码串行调用
|
||||
// 关闭 issue 本身是一个 PATCH 请求,评论是另一个 POST 请求到 journals 端点
|
||||
if comment := ctx.Arg("comment"); comment != "" {
|
||||
_, _ = ctx.CallAPI("POST",
|
||||
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
|
||||
map[string]interface{}{"notes": comment})
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修的字段(标题/描述/状态/里程碑/指派人/标签/优先级等)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue",
|
||||
Description: "更新疑修",
|
||||
Long: `Update the title, description, status, or other fields of an existing issue.
|
||||
|
||||
At least one of --title, --body, --state, --milestone, --assignee,
|
||||
--label, or --priority must be provided.
|
||||
The --state flag accepts "open", "closed", or a numeric status_id.`,
|
||||
Example: ` # Change the title
|
||||
gitlink issue +update --number 42 --title "Updated title"
|
||||
|
||||
# Change description
|
||||
gitlink issue +update -n 42 --body "New description"
|
||||
|
||||
# Reopen a closed issue
|
||||
gitlink issue +update -n 42 --state open
|
||||
|
||||
# Update title and close at the same time
|
||||
gitlink issue +update -n 42 --title "Fixed" --state closed
|
||||
|
||||
# Assign to a user and set milestone
|
||||
gitlink issue +update -n 42 --assignee 5 --milestone 3
|
||||
|
||||
# Set label and priority
|
||||
gitlink issue +update -n 42 --label 7 --priority 1`,
|
||||
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"},
|
||||
{Name: "body", Short: "b", Usage: "New description"},
|
||||
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
|
||||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
|
||||
{Name: "label", Usage: "Label ID"},
|
||||
{Name: "priority", Usage: "Priority ID"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -159,8 +293,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
title := ctx.Arg("title")
|
||||
description := ctx.Arg("body")
|
||||
state := ctx.Arg("state")
|
||||
if title == "" && description == "" && state == "" {
|
||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
||||
milestone := ctx.Arg("milestone")
|
||||
assignee := ctx.Arg("assignee")
|
||||
label := ctx.Arg("label")
|
||||
priority := ctx.Arg("priority")
|
||||
if title == "" && description == "" && state == "" &&
|
||||
milestone == "" && assignee == "" && label == "" && priority == "" {
|
||||
return fmt.Errorf("at least one of --title, --body, --state, --milestone, --assignee, --label, or --priority is required")
|
||||
}
|
||||
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
|
|
@ -168,6 +307,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
|
||||
// [P2 参数补全] 保留原值,只修改用户指定的字段
|
||||
// GitLink PATCH API 要求 subject 和 description 不能为空,所以必须先查出原值
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
|
|
@ -185,6 +326,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
// [P2 参数补全] 以下 4 个参数原 CLI 未暴露
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
body["priority_id"] = p // CLI "priority" → API "priority_id"
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -192,9 +346,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在指定疑修下添加一条评论(--number 和 --body 必填)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to an issue",
|
||||
Description: "在疑修下添加评论",
|
||||
Long: `Add a comment (journal entry) to an existing issue.
|
||||
|
||||
Both --number and --body are required.`,
|
||||
Example: ` # Comment on issue #42
|
||||
gitlink issue +comment --number 42 --body "This is now fixed."
|
||||
|
||||
# Short form
|
||||
gitlink issue +comment -n 42 -b "LGTM"`,
|
||||
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},
|
||||
|
|
@ -221,6 +384,236 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出指定疑修下的评论与动态记录
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出疑修的评论与动态",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", 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
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
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("%s/issues/%s/journals", v1RepoPath(ctx), number), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改疑修下的某条评论内容
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改疑修下的评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Comment journal ID", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除疑修下的某条评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除疑修下的评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Comment journal ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reply-comment:列出某条评论的子回复
|
||||
{
|
||||
Name: "reply-comment",
|
||||
Description: "列出评论的子回复",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Parent comment journal ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定疑修
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修",
|
||||
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")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-update:通过原生批量接口批量更新多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-update",
|
||||
Description: "批量更新多个疑修",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
{Name: "state", Short: "s", Usage: "New state: open, closed"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee login"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
idsStr, err := ctx.RequireArg("ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"ids": parseIDList(idsStr),
|
||||
}
|
||||
if s := ctx.Arg("state"); s != "" {
|
||||
statusID, err := normalizeIssueStatus(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-destroy:通过原生批量接口批量删除多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-destroy",
|
||||
Description: "批量删除多个疑修",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
idsStr, err := ctx.RequireArg("ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"ids": parseIDList(idsStr),
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
|
||||
// "id" to "database_id" so the user-facing output uses the project-level
|
||||
// issue number, not the global database primary key.
|
||||
func normalizeIssueListIDs(env *output.Envelope) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issues, ok := data["issues"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for i, item := range issues {
|
||||
issue, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Copy project_issues_index to top-level "number"
|
||||
if num, ok := issue["project_issues_index"]; ok {
|
||||
issue["number"] = num
|
||||
}
|
||||
// Rename "id" (global database PK) to "database_id"
|
||||
if id, ok := issue["id"]; ok {
|
||||
issue["database_id"] = id
|
||||
delete(issue, "id")
|
||||
}
|
||||
issues[i] = issue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,3 +650,19 @@ func normalizeIssueStatus(state string) (interface{}, error) {
|
|||
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
|
||||
}
|
||||
}
|
||||
|
||||
// parseIDList splits a comma-separated string into an int slice.
|
||||
func parseIDList(s string) []int {
|
||||
parts := strings.Split(s, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := strconv.Atoi(p); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,3 +186,182 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDelete(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||
writeJSON(t, w, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "delete", map[string]string{"number": "42"})
|
||||
if err != nil {
|
||||
t.Fatalf("delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueComments(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals.json":
|
||||
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "10" {
|
||||
t.Fatalf("unexpected query params: %v", r.URL.Query())
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{"id": 1, "notes": "first comment"},
|
||||
map[string]interface{}{"id": 2, "notes": "second comment"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "comments", map[string]string{
|
||||
"number": "42",
|
||||
"page": "2",
|
||||
"limit": "10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comments shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueUpdateComment(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "update-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
"body": "updated comment text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-comment shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["notes"], "updated comment text")
|
||||
}
|
||||
|
||||
func TestIssueDeleteComment(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
|
||||
writeJSON(t, w, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "delete-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-comment shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueReplyComment(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99/children_journals.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{"id": 100, "notes": "reply"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "reply-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reply-comment shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueBatchUpdate(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-update", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"state": "closed",
|
||||
"assignee": "someone",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-update shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
ids, ok := updatePayload["ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
|
||||
}
|
||||
assertEqual(t, len(ids), 3)
|
||||
assertEqual(t, ids[0], float64(1))
|
||||
assertEqual(t, ids[1], float64(2))
|
||||
assertEqual(t, ids[2], float64(3))
|
||||
assertEqual(t, updatePayload["status_id"], float64(5))
|
||||
assertEqual(t, updatePayload["assigned_to_id"], "someone")
|
||||
}
|
||||
|
||||
func TestIssueBatchDestroy(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
|
||||
"ids": "10,20,30",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-destroy shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
ids, ok := updatePayload["ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
|
||||
}
|
||||
assertEqual(t, len(ids), 3)
|
||||
assertEqual(t, ids[0], float64(10))
|
||||
assertEqual(t, ids[1], float64(20))
|
||||
assertEqual(t, ids[2], float64(30))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package label
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的疑修标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的疑修标签",
|
||||
Long: `List repository issue labels (tags).
|
||||
|
||||
Returns all labels defined in the repository.`,
|
||||
Example: ` # List all labels
|
||||
gitlink label +list`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建疑修标签
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建疑修标签",
|
||||
Long: `Create an issue label.
|
||||
|
||||
Creates a new label with the given name.
|
||||
Optionally specify a color in hex format (without #).`,
|
||||
Example: ` # Create a label
|
||||
gitlink label +create --name bug
|
||||
|
||||
# Create a label with color
|
||||
gitlink label +create --name enhancement --color 00ff00`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Label name", Required: true},
|
||||
{Name: "color", Short: "c", Usage: "Label color (hex without #, e.g. ff0000)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if color := ctx.Arg("color"); color != "" {
|
||||
payload["color"] = color
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修标签
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新疑修标签",
|
||||
Long: `Update an issue label.
|
||||
|
||||
Updates the name and/or color of an existing label.
|
||||
At least one of --name or --color should be provided.`,
|
||||
Example: ` # Rename a label
|
||||
gitlink label +update --id 42 --name "new name"
|
||||
|
||||
# Change label color
|
||||
gitlink label +update --id 42 --color ff0000`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New label name"},
|
||||
{Name: "color", Short: "c", Usage: "New label color (hex without #)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if color := ctx.Arg("color"); color != "" {
|
||||
payload["color"] = color
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除疑修标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修标签",
|
||||
Long: `Delete an issue label.
|
||||
|
||||
Permanently removes the label from the repository.`,
|
||||
Example: ` # Delete a label
|
||||
gitlink label +delete --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +priorities:列出可用的疑修优先级
|
||||
{
|
||||
Name: "priorities",
|
||||
Description: "列出可用的疑修优先级",
|
||||
Long: `List available issue priorities.
|
||||
|
||||
Returns the priority levels that can be assigned to issues.`,
|
||||
Example: ` # List issue priorities
|
||||
gitlink label +priorities`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_priorities", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +statuses:列出可用的疑修状态
|
||||
{
|
||||
Name: "statuses",
|
||||
Description: "列出可用的疑修状态",
|
||||
Long: `List available issue statuses.
|
||||
|
||||
Returns the status values that issues can have
|
||||
(e.g., open, closed, in progress).`,
|
||||
Example: ` # List issue statuses
|
||||
gitlink label +statuses`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_statues", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +authors:列出可用的疑修作者
|
||||
{
|
||||
Name: "authors",
|
||||
Description: "列出可用的疑修作者",
|
||||
Long: `List available issue authors.
|
||||
|
||||
Returns the users who have authored issues in the repository.`,
|
||||
Example: ` # List issue authors
|
||||
gitlink label +authors`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_authors", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +assigners:列出可用的疑修复办人
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "列出可用的疑修复办人",
|
||||
Long: `List available issue assigners.
|
||||
|
||||
Returns the users who can be assigned to issues in the repository.`,
|
||||
Example: ` # List issue assigners
|
||||
gitlink label +assigners`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_assigners", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有里程碑
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有里程碑",
|
||||
Long: `List repository milestones.
|
||||
|
||||
Displays all milestones for the repository with optional filtering by state.
|
||||
Supports pagination with --page and --limit flags.`,
|
||||
Example: ` # List open milestones
|
||||
gitlink milestone +list
|
||||
|
||||
# List closed milestones
|
||||
gitlink milestone +list -s closed
|
||||
|
||||
# List all milestones with pagination
|
||||
gitlink milestone +list -s all -p 2 -l 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "state", Short: "s", Usage: "Filter: open or closed or all", Default: "open"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建里程碑
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建里程碑",
|
||||
Long: `Create a milestone.
|
||||
|
||||
Creates a new milestone with the given title. Optionally add a description
|
||||
and due date.`,
|
||||
Example: ` # Create a basic milestone
|
||||
gitlink milestone +create -t "v1.0"
|
||||
|
||||
# Create a milestone with description and due date
|
||||
gitlink milestone +create -t "v2.0" -d "Second major release" --due-date 2025-12-31`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Milestone title", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Milestone description"},
|
||||
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": title,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
if dueDate := ctx.Arg("due-date"); dueDate != "" {
|
||||
payload["effective_date"] = dueDate
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看里程碑的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看里程碑的详细信息",
|
||||
Long: `View milestone details.
|
||||
|
||||
Shows detailed information about a specific milestone including its
|
||||
title, description, status, due date, and completion progress.`,
|
||||
Example: ` # View milestone details by ID
|
||||
gitlink milestone +view -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新里程碑
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新里程碑",
|
||||
Long: `Update a milestone.
|
||||
|
||||
Modifies an existing milestone's title, description, due date, or status.
|
||||
Only the fields you specify will be changed.`,
|
||||
Example: ` # Update a milestone title
|
||||
gitlink milestone +update -i 42 -t "v1.0 Final"
|
||||
|
||||
# Update description and due date
|
||||
gitlink milestone +update -i 42 -d "Final release" --due-date 2025-06-30
|
||||
|
||||
# Close a milestone via update
|
||||
gitlink milestone +update -i 42 --status close`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
{Name: "due-date", Usage: "New due date (YYYY-MM-DD)"},
|
||||
{Name: "status", Usage: "Status: open or close"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title := ctx.Arg("title"); title != "" {
|
||||
payload["name"] = title
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
if dueDate := ctx.Arg("due-date"); dueDate != "" {
|
||||
payload["effective_date"] = dueDate
|
||||
}
|
||||
if status := ctx.Arg("status"); status != "" {
|
||||
payload["status"] = status
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除里程碑
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除里程碑",
|
||||
Long: `Delete a milestone.
|
||||
|
||||
Permanently removes a milestone from the repository. This action cannot be undone.`,
|
||||
Example: ` # Delete a milestone by ID
|
||||
gitlink milestone +delete -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭里程碑
|
||||
{
|
||||
Name: "close",
|
||||
Description: "关闭里程碑",
|
||||
Long: `Close a milestone.
|
||||
|
||||
Marks a milestone as closed. Closed milestones are still visible but
|
||||
can no longer receive new issues.`,
|
||||
Example: ` # Close a milestone by ID
|
||||
gitlink milestone +close -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"status": "close",
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,19 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出组织
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
Description: "列出组织",
|
||||
Long: `List all organizations visible to the authenticated user.
|
||||
|
||||
Returns organization ID, name, and description for each entry.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List organizations
|
||||
gitlink org +list
|
||||
|
||||
# Show 50 organizations per page
|
||||
gitlink org +list --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
|
|
@ -27,9 +37,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:查看组织详情
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show organization details",
|
||||
Description: "查看组织详情",
|
||||
Long: `Show detailed information about an organization.
|
||||
|
||||
Returns the full organization profile including name, description,
|
||||
member count, and repository list.`,
|
||||
Example: ` # Show details for an organization by ID
|
||||
gitlink org +info --id 123
|
||||
|
||||
# Show details for an organization by login
|
||||
gitlink org +info --id my-org`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
|
|
@ -42,9 +62,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +members:列出组织成员
|
||||
{
|
||||
Name: "members",
|
||||
Description: "List organization members",
|
||||
Description: "列出组织成员",
|
||||
Long: `List all members of an organization.
|
||||
|
||||
Shows each member's login name, role, and join date.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List members of an organization
|
||||
gitlink org +members --id 123
|
||||
|
||||
# Show 50 members per page
|
||||
gitlink org +members --id 123 --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
|
|
@ -62,9 +92,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建组织
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create an organization",
|
||||
Description: "创建组织",
|
||||
Long: `Create a new organization on GitLink.
|
||||
|
||||
Requires a name for the organization. An optional description
|
||||
can be provided with --description.`,
|
||||
Example: ` # Create an organization
|
||||
gitlink org +create --name my-org
|
||||
|
||||
# Create with a description
|
||||
gitlink org +create --name my-org --description "My team organization"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
|
|
@ -84,5 +124,41 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add-team-projects:为团队添加全部项目
|
||||
{
|
||||
Name: "add-team-projects",
|
||||
Description: "为团队添加全部项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org")
|
||||
team, _ := ctx.RequireArg("team")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/create_all", org, team), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove-team-projects:移除团队的全部项目
|
||||
{
|
||||
Name: "remove-team-projects",
|
||||
Description: "移除团队的全部项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org")
|
||||
team, _ := ctx.RequireArg("team")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/destroy_all", org, team), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按编号或 CSV 文件批量关闭多个合并请求(支持 --dry-run 预览)
|
||||
Name: "batch-close",
|
||||
Description: "批量关闭多个合并请求",
|
||||
Long: `Close multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --dry-run to preview which pull requests would be closed without
|
||||
making changes.
|
||||
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Close pull requests 1, 2, and 3
|
||||
gitlink pr +batch-close --numbers 1,2,3
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink pr +batch-close --numbers 1,2,3 --dry-run
|
||||
|
||||
# Close pull requests listed in a CSV file
|
||||
gitlink pr +batch-close --from prs.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||
{Name: "dry-run", Usage: "Preview the pull requests that would be closed without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchClose,
|
||||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 PR——POST /pulls/{n}/refuse_merge,每个请求独立
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), number), nil)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d pull request(s) failed to close", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchMergeShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-merge:按编号或 CSV 文件批量合并多个合并请求(不可逆,默认需确认)
|
||||
Name: "batch-merge",
|
||||
Description: "批量合并多个合并请求",
|
||||
Long: `Merge multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --method to choose the merge strategy: merge (default), rebase, or squash.
|
||||
Use --dry-run to preview which pull requests would be merged without
|
||||
making changes.
|
||||
|
||||
WARNING: Merging is irreversible. A confirmation prompt is shown before
|
||||
execution (use --yes to auto-confirm).
|
||||
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Merge pull requests 1, 2, and 3 using the default method
|
||||
gitlink pr +batch-merge --numbers 1,2,3
|
||||
|
||||
# Squash-merge multiple pull requests
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --method squash
|
||||
|
||||
# Rebase-merge pull requests listed in a CSV file
|
||||
gitlink pr +batch-merge --from prs.csv --method rebase
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --dry-run
|
||||
|
||||
# Auto-confirm without prompt
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --yes`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
{Name: "dry-run", Usage: "Preview the pull requests that would be merged without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchMerge,
|
||||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个合并 PR——POST /pulls/{n}/pr_merge
|
||||
// 不可逆操作:执行前调用 ConfirmAction 确认(除非 --yes)
|
||||
func runBatchMerge(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge" // 默认使用 merge 方式
|
||||
}
|
||||
// P2 补全的 validateMergeMethod:只允许 merge/rebase/squash 三种
|
||||
if err := validateMergeMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if dryRun {
|
||||
// dry-run 直接返回预览,不给 fn 传实际逻辑
|
||||
summary := common.ProcessBatch(numbers, true, "merge", nil)
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
return ctx.OutputData(summary)
|
||||
}
|
||||
|
||||
// Merging is irreversible — require explicit confirmation
|
||||
if err := common.ConfirmAction(fmt.Sprintf("batch merge %d pull requests", len(numbers))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 逐个调用 POST /pulls/{n}/pr_merge,每个请求带合并方式参数
|
||||
summary := common.ProcessBatch(numbers, false, "merge", func(number string) error {
|
||||
payload := map[string]interface{}{
|
||||
"do": method, // merge/rebase/squash
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), number), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d pull request(s) failed to merge", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package pr
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -10,15 +11,51 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchMergeShortcut(),
|
||||
// +list:列出当前仓库的合并请求(支持按状态/里程碑/标签/评审人筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List pull requests",
|
||||
Description: "列出合并请求",
|
||||
Long: `List pull requests in the current repository.
|
||||
|
||||
Shows PR number, title, status, author, and other details.
|
||||
Use --state to filter by status (open, merged, closed).
|
||||
Use --keyword, --milestone, --tag, --reviewer, --assignee for
|
||||
additional filtering, and --sort to control ordering.`,
|
||||
Example: ` # List open pull requests
|
||||
gitlink pr +list
|
||||
|
||||
# List merged pull requests
|
||||
gitlink pr +list --state merged
|
||||
|
||||
# List closed pull requests with pagination
|
||||
gitlink pr +list --state closed --page 2 --limit 10
|
||||
|
||||
# Search by keyword
|
||||
gitlink pr +list -k "feature"
|
||||
|
||||
# Filter by milestone and assignee
|
||||
gitlink pr +list --milestone 3 --assignee 5
|
||||
|
||||
# Sort by created date
|
||||
gitlink pr +list --sort created_on
|
||||
|
||||
# Sort by updated date descending
|
||||
gitlink pr +list --sort updated_on --sort-direction desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
|
||||
{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 {
|
||||
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
|
||||
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
|
||||
{Name: "tag", Usage: "Filter by tag ID"},
|
||||
{Name: "reviewer", Usage: "Filter by reviewer ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Filter by assignee ID"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -28,6 +65,28 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
// [P2 参数补全] 以下 7 个查询参数原 CLI 未暴露
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("version_id", m) // CLI "milestone" → API "version_id"(PR 的里程碑字段名和 issue 不同)
|
||||
}
|
||||
if t := ctx.Arg("tag"); t != "" {
|
||||
q.Set("issue_tag_id", t) // CLI "tag" → API "issue_tag_id"
|
||||
}
|
||||
if r := ctx.Arg("reviewer"); r != "" {
|
||||
q.Set("reviewer_id", r) // CLI "reviewer" → API "reviewer_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assign_user_id", a) // CLI "assignee" → API "assign_user_id"
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -35,14 +94,36 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建新合并请求(需 --title 和 --head 源分支,目标分支默认仓库默认分支)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a pull request",
|
||||
Description: "创建合并请求",
|
||||
Long: `Create a new pull request in the current repository.
|
||||
|
||||
Requires a title and source branch (--head). The target branch
|
||||
(--base) defaults to the repository default branch if not specified.
|
||||
Use --assignee, --milestone, --label, and --priority to set
|
||||
additional fields at creation time.`,
|
||||
Example: ` # Create a pull request
|
||||
gitlink pr +create --title "Add new feature" --head feature-branch
|
||||
|
||||
# Create a PR with description and target branch
|
||||
gitlink pr +create --title "Fix bug" --body "Fixes #123" --head fix-branch --base main
|
||||
|
||||
# Create with assignee and milestone
|
||||
gitlink pr +create --title "Feature" --head feat --assignee 5 --milestone 3
|
||||
|
||||
# Create with labels and priority
|
||||
gitlink pr +create --title "Feature" --head feat --label 7 --priority 1`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "PR description"},
|
||||
{Name: "head", Usage: "Source branch", Required: true},
|
||||
{Name: "base", Usage: "Target branch", Default: "master"},
|
||||
{Name: "base", Usage: "Target branch (default: repository default branch)"},
|
||||
{Name: "priority", Usage: "Priority ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
|
||||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
||||
{Name: "label", Short: "l", Usage: "Label ID (comma-separated for multiple)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -51,8 +132,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
// [P1 Bug#2] 原代码: base = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if base == "" {
|
||||
base = "master"
|
||||
base = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
|
|
@ -62,6 +145,32 @@ func Shortcuts() []*common.Shortcut {
|
|||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
// [P2 参数补全] PR 创建时支持 priority、assignee、milestone、label
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
payload["priority_id"] = p // CLI "priority" → API "priority_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
payload["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
payload["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
// [P2 参数补全] PR 的标签需要数组格式 issue_tag_ids(和 issue 的单值 issue_tag_id 不同)
|
||||
// 支持逗号分隔多标签:--label "1,2,3" → [1, 2, 3]
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
if strings.Contains(l, ",") {
|
||||
// 多标签:拆分、去空格、转为 []interface{} 数组
|
||||
parts := strings.Split(l, ",")
|
||||
ids := make([]interface{}, len(parts))
|
||||
for i, p := range parts {
|
||||
ids[i] = strings.TrimSpace(p)
|
||||
}
|
||||
payload["issue_tag_ids"] = ids
|
||||
} else {
|
||||
// 单标签也用数组格式
|
||||
payload["issue_tag_ids"] = []interface{}{l}
|
||||
}
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -69,9 +178,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定合并请求的详情(标题/描述/状态/作者/分支等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View pull request details",
|
||||
Description: "查看合并请求详情",
|
||||
Long: `View detailed information about a specific pull request.
|
||||
|
||||
Displays PR title, description, status, author, branches,
|
||||
and other metadata.`,
|
||||
Example: ` # View details of pull request #42
|
||||
gitlink pr +view --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -87,12 +203,30 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +merge:合并合并请求(支持 merge/rebase/squash 三种方式,可自定义提交信息)
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
Description: "合并合并请求",
|
||||
Long: `Merge a pull request using the specified merge method.
|
||||
|
||||
Supported merge methods: merge (default), rebase, squash.
|
||||
Use --title and --body to customize the merge commit message.`,
|
||||
Example: ` # Merge a pull request using the default method
|
||||
gitlink pr +merge --id 42
|
||||
|
||||
# Squash-merge a pull request
|
||||
gitlink pr +merge --id 42 --method squash
|
||||
|
||||
# Rebase-merge a pull request
|
||||
gitlink pr +merge --id 42 --method rebase
|
||||
|
||||
# Merge with custom commit title and description
|
||||
gitlink pr +merge --id 42 --title "Merge feature X" --body "Combines feature X into main"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
{Name: "title", Usage: "Merge commit title"},
|
||||
{Name: "body", Usage: "Merge commit description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -103,9 +237,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
if err := validateMergeMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"do": method,
|
||||
}
|
||||
if t := ctx.Arg("title"); t != "" {
|
||||
payload["title"] = t
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -113,9 +256,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭合并请求(不合并,关闭后需重新开启才能合并)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "Close a pull request",
|
||||
Description: "关闭合并请求",
|
||||
Long: `Close a pull request without merging.
|
||||
|
||||
The PR will be marked as closed and cannot be merged afterwards
|
||||
unless it is reopened.`,
|
||||
Example: ` # Close pull request #42
|
||||
gitlink pr +close --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -131,9 +281,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +files:列出合并请求中的变更文件(路径/变更类型/增删行数)
|
||||
{
|
||||
Name: "files",
|
||||
Description: "List changed files in a pull request",
|
||||
Description: "列出合并请求的变更文件",
|
||||
Long: `List all files changed in a pull request.
|
||||
|
||||
Shows the file path, change type (added, modified, deleted),
|
||||
and the number of additions and deletions for each file.`,
|
||||
Example: ` # List files changed in pull request #42
|
||||
gitlink pr +files --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -149,9 +306,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +versions:列出合并请求的补丁集版本历史
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a pull request",
|
||||
Name: "versions",
|
||||
Description: "列出合并请求的补丁集版本",
|
||||
Long: `List all patchset versions of a pull request.
|
||||
|
||||
Each time a PR is updated with new commits, a new patchset version
|
||||
is created. This command shows the version history.`,
|
||||
Example: ` # List patchset versions for pull request #42
|
||||
gitlink pr +versions --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -159,17 +323,383 @@ 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")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/versions", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
|
||||
// 修复: 将 +diff 改名为 +version-diff,指向 PR 补丁集版本的差异 API
|
||||
// +version-diff:查看合并请求某补丁集版本的差异(可用 --file 过滤指定文件)
|
||||
// +files 仍然用于查看 PR 文件列表,两者功能区分开
|
||||
{
|
||||
Name: "version-diff",
|
||||
Description: "查看合并请求补丁集版本的差异",
|
||||
Long: `Show the diff for a specific patchset version of a pull request.
|
||||
|
||||
Use --file to filter the diff to a specific file path.`,
|
||||
Example: ` # Show diff for version 3 of pull request #42
|
||||
gitlink pr +version-diff --id 42 --version-id 3
|
||||
|
||||
# Show diff for a specific file in a patchset version
|
||||
gitlink pr +version-diff --id 42 --version-id 3 --file src/main.go`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
|
||||
{Name: "file", Short: "f", Usage: "Filter diff by file path"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionID, err := ctx.RequireArg("version-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/versions/%s/diff", prV1Path(ctx, id), versionID)
|
||||
if file := ctx.Arg("file"); file != "" {
|
||||
q := url.Values{}
|
||||
q.Set("filepath", file)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reviews:列出合并请求的评审记录(可按评审状态筛选)
|
||||
{
|
||||
Name: "reviews",
|
||||
Description: "列出合并请求的评审",
|
||||
Long: `List all reviews submitted for a pull request.
|
||||
|
||||
Use --status to filter reviews by their status:
|
||||
common, approved, or rejected.`,
|
||||
Example: ` # List all reviews for pull request #42
|
||||
gitlink pr +reviews --id 42
|
||||
|
||||
# List only approved reviews
|
||||
gitlink pr +reviews --id 42 --status approved`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if status := ctx.Arg("status"); status != "" {
|
||||
if err := validatePRReviewStatus(status); err != nil {
|
||||
return err
|
||||
}
|
||||
q.Set("status", status)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", prV1Path(ctx, id)+"/reviews", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +review:对合并请求提交评审(common/approved/rejected,支持 --dry-run 预览)
|
||||
{
|
||||
Name: "review",
|
||||
Description: "创建合并请求评审",
|
||||
Long: `Submit a review on a pull request.
|
||||
|
||||
Supported review statuses: common (comment), approved, rejected.
|
||||
Use --commit to attach the review to a specific commit.
|
||||
Use --dry-run to preview the review without submitting it.`,
|
||||
Example: ` # Submit an approving review
|
||||
gitlink pr +review --id 42 --status approved --content "Looks good!"
|
||||
|
||||
# Request changes on a PR
|
||||
gitlink pr +review --id 42 --status rejected --content "Needs fixes"
|
||||
|
||||
# Preview a review without creating it
|
||||
gitlink pr +review --id 42 --status common --content "Note" --dry-run`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
|
||||
{Name: "content", Short: "c", Usage: "Review content", Required: true},
|
||||
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
|
||||
{Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := ctx.Arg("status")
|
||||
if status == "" {
|
||||
status = "common"
|
||||
}
|
||||
if err := validatePRReviewStatus(status); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"content": content,
|
||||
"status": status,
|
||||
}
|
||||
if commit := ctx.Arg("commit"); commit != "" {
|
||||
payload["commit_id"] = commit
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"pull_request": id,
|
||||
"dry_run": true,
|
||||
"action": "create_review",
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reviews", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出合并请求下的评审评论
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出合并请求的评审评论",
|
||||
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, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-comment:在合并请求下创建一条评审评论(可附行号与文件路径)
|
||||
{
|
||||
Name: "create-comment",
|
||||
Description: "创建合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
{Name: "line", Short: "l", Usage: "Line number"},
|
||||
{Name: "path", Short: "p", Usage: "File path"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
if line := ctx.Arg("line"); line != "" {
|
||||
payload["line"] = line
|
||||
}
|
||||
if path := ctx.Arg("path"); path != "" {
|
||||
payload["path"] = path
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改合并请求下的某条评审评论
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除合并请求下的某条评审评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +commits:列出合并请求中包含的提交记录
|
||||
{
|
||||
Name: "commits",
|
||||
Description: "列出合并请求的提交",
|
||||
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, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reopen:重新打开已关闭的合并请求
|
||||
{
|
||||
Name: "reopen",
|
||||
Description: "重新打开合并请求",
|
||||
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, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新合并请求的标题和/或描述
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新合并请求的标题或描述",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "body", Short: "b", Usage: "New description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := ctx.Arg("title")
|
||||
body := ctx.Arg("body")
|
||||
if title == "" && body == "" {
|
||||
return fmt.Errorf("at least one of --title or --body is required")
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
if body != "" {
|
||||
payload["body"] = body
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在合并请求下添加评论(作为关联疑修的日志条目发布)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to a pull request",
|
||||
Description: "在合并请求下添加评论",
|
||||
Long: `Add a comment to a pull request.
|
||||
|
||||
The comment is posted as a journal entry on the underlying issue
|
||||
associated with the pull request.`,
|
||||
Example: ` # Add a comment to pull request #42
|
||||
gitlink pr +comment --id 42 --body "This looks great, thanks!"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -203,6 +733,30 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
}
|
||||
|
||||
func prV1Path(ctx *common.RuntimeContext, id string) string {
|
||||
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
|
||||
}
|
||||
|
||||
func validatePRReviewStatus(status string) error {
|
||||
switch status {
|
||||
case "common", "approved", "rejected":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status)
|
||||
}
|
||||
}
|
||||
|
||||
// [P2 参数补全] 校验合并方式参数,只允许 merge/rebase/squash 三种合法值
|
||||
// 这是纯前端校验,在发送 API 请求之前拦截非法输入
|
||||
func validateMergeMethod(method string) error {
|
||||
switch method {
|
||||
case "merge", "rebase", "squash":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --method %q: use merge, rebase, or squash", method)
|
||||
}
|
||||
}
|
||||
|
||||
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,455 @@ func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPRVersionsUsesV1Endpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/versions.json" {
|
||||
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(2),
|
||||
"versions": []map[string]interface{}{
|
||||
{
|
||||
"id": float64(16039),
|
||||
"head_commit_sha": "aaaaaaaa",
|
||||
},
|
||||
{
|
||||
"id": float64(16040),
|
||||
"head_commit_sha": "bbbbbbbb",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "versions", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("versions shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/versions.json")
|
||||
}
|
||||
|
||||
func TestPRVersionDiffUsesV1EndpointWithFileFilter(t *testing.T) {
|
||||
var calledPath string
|
||||
var filepath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/versions/16040/diff.json" {
|
||||
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
filepath = r.URL.Query().Get("filepath")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"diff": "--- a/shortcuts/pr/pr.go\n+++ b/shortcuts/pr/pr.go\n",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "version-diff", map[string]string{
|
||||
"id": "13",
|
||||
"version-id": "16040",
|
||||
"file": "shortcuts/pr/pr.go",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("version-diff shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/versions/16040/diff.json")
|
||||
assertEqual(t, filepath, "shortcuts/pr/pr.go")
|
||||
}
|
||||
|
||||
func TestPRVersionDiffRequiresVersionID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when version-id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "version-diff", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when version-id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewsUsesV1EndpointWithStatusFilter(t *testing.T) {
|
||||
var calledPath string
|
||||
var status string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/reviews.json" {
|
||||
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
status = r.URL.Query().Get("status")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"reviews": []map[string]interface{}{
|
||||
{
|
||||
"id": float64(100),
|
||||
"content": "LGTM",
|
||||
"status": "approved",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "reviews", map[string]string{
|
||||
"id": "13",
|
||||
"status": "approved",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reviews shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reviews.json")
|
||||
assertEqual(t, status, "approved")
|
||||
}
|
||||
|
||||
func TestPRReviewPostsReviewPayload(t *testing.T) {
|
||||
var reviewPayload map[string]interface{}
|
||||
var reviewPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reviews.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
reviewPath = r.URL.Path
|
||||
reviewPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(101),
|
||||
"content": "Looks good",
|
||||
"status": "approved",
|
||||
"commit_id": "abc123",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"status": "approved",
|
||||
"content": "Looks good",
|
||||
"commit": "abc123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, reviewPath, "/v1/owner/repo/pulls/13/reviews.json")
|
||||
assertEqual(t, reviewPayload["content"], "Looks good")
|
||||
assertEqual(t, reviewPayload["status"], "approved")
|
||||
assertEqual(t, reviewPayload["commit_id"], "abc123")
|
||||
}
|
||||
|
||||
func TestPRReviewDryRunDoesNotCallAPI(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called during dry-run: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"status": "rejected",
|
||||
"content": "Please fix the failing tests",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewRejectsInvalidStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called for invalid status: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"status": "approve",
|
||||
"content": "LGTM",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid review status, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task 2: PR Comments CRUD, Commits, Reopen, Update tests ---
|
||||
|
||||
func TestPRCommentsListsReviewComments(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"journals": []map[string]interface{}{
|
||||
{
|
||||
"id": float64(501),
|
||||
"notes": "Looks good here",
|
||||
"line": float64(42),
|
||||
"path": "main.go",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "comments", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comments shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
|
||||
}
|
||||
|
||||
func TestPRCreateCommentPostsJournal(t *testing.T) {
|
||||
var journalPayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
journalPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(502),
|
||||
"notes": "nit: use camelCase",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "create-comment", map[string]string{
|
||||
"id": "13",
|
||||
"body": "nit: use camelCase",
|
||||
"line": "42",
|
||||
"path": "main.go",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
|
||||
assertEqual(t, journalPayload["notes"], "nit: use camelCase")
|
||||
assertEqual(t, journalPayload["line"], float64(42))
|
||||
assertEqual(t, journalPayload["path"], "main.go")
|
||||
}
|
||||
|
||||
func TestPRCreateCommentRequiresBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when body is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "create-comment", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when body is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRUpdateCommentPutsJournal(t *testing.T) {
|
||||
var journalPayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
journalPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(501),
|
||||
"notes": "updated comment text",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-comment", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "501",
|
||||
"body": "updated comment text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
|
||||
assertEqual(t, journalPayload["notes"], "updated comment text")
|
||||
}
|
||||
|
||||
func TestPRUpdateCommentRequiresCommentID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-comment", map[string]string{
|
||||
"id": "13",
|
||||
"body": "some text",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when comment-id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRDeleteCommentDeletesJournal(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "delete-comment", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "501",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
|
||||
}
|
||||
|
||||
func TestPRDeleteCommentRequiresCommentID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "delete-comment", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when comment-id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommitsListsCommits(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/13/commits.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(2),
|
||||
"commits": []map[string]interface{}{
|
||||
{
|
||||
"sha": "abc123",
|
||||
"author": "dev1",
|
||||
},
|
||||
{
|
||||
"sha": "def456",
|
||||
"author": "dev2",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "commits", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commits shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/pulls/13/commits.json")
|
||||
}
|
||||
|
||||
func TestPRReopenReopensPR(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"status": "open",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "reopen", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json")
|
||||
}
|
||||
|
||||
func TestPRUpdateUpdatesTitle(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"title": "new title",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
"title": "new title",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/pulls/13.json")
|
||||
assertEqual(t, updatePayload["title"], "new title")
|
||||
}
|
||||
|
||||
func TestPRUpdateUpdatesBody(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"body": "new body",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
"body": "new body",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, updatePayload["body"], "new body")
|
||||
}
|
||||
|
||||
func TestPRUpdateRequiresTitleOrBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when both title and body are missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both title and body are missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findPRShortcut(t, name)
|
||||
|
|
|
|||
|
|
@ -3,42 +3,84 @@ package shortcuts
|
|||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/collaborator"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
|
||||
"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/snippet"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/sshkey"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/tag"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/template"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/util"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"snippet": snippet.Shortcuts(),
|
||||
"collaborator": collaborator.Shortcuts(),
|
||||
"tag": tag.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"file": file.Shortcuts(),
|
||||
"commit": commit.Shortcuts(),
|
||||
"label": label.Shortcuts(),
|
||||
"sshkey": sshkey.Shortcuts(),
|
||||
"util": util.Shortcuts(),
|
||||
"dataset": dataset.Shortcuts(),
|
||||
"template": template.Shortcuts(),
|
||||
"attachment": attachment.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"wiki": "Wiki operations",
|
||||
"snippet": "Code snippet management",
|
||||
"collaborator": "Collaborator management",
|
||||
"tag": "Git tag operations",
|
||||
"milestone": "Milestone management",
|
||||
"file": "File content operations",
|
||||
"commit": "Commit operations",
|
||||
"label": "Issue label management",
|
||||
"sshkey": "SSH key management",
|
||||
"util": "Utility operations (licenses, ignores, settings)",
|
||||
"dataset": "Dataset management",
|
||||
"template": "Project template management",
|
||||
"attachment": "File attachment operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
@ -50,3 +92,62 @@ func RegisterAll(root *cobra.Command) {
|
|||
root.AddCommand(groupCmd)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllShortcuts 返回全部命令组注册表,供交互式模式读取。
|
||||
// 每个命令组(如 issue)包含若干子命令(如 +list / +create)。
|
||||
func GetAllShortcuts() map[string][]*common.Shortcut {
|
||||
return map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(), // 仓库管理:列表/详情/创建/Fork/删除/更新/Star/Watch 等
|
||||
"issue": issue.Shortcuts(), // 疑修(Issue)管理:列表/创建/查看/关闭/更新/评论等
|
||||
"pr": pr.Shortcuts(), // 合并请求(Pull Request)管理:列表/创建/合并/评审/评论等
|
||||
"release": release.Shortcuts(), // 发行版(Release)管理:列表/创建/查看/删除/更新等
|
||||
"branch": branch.Shortcuts(), // 分支管理:列表/创建/删除/保护/切换默认分支等
|
||||
"org": org.Shortcuts(), // 组织(Organization)管理:信息/成员/项目等
|
||||
"user": user.Shortcuts(), // 用户管理:个人信息/关注/粉丝等
|
||||
"search": search.Shortcuts(), // 搜索:仓库/疑修/用户等全局搜索
|
||||
"ci": ci.Shortcuts(), // CI/CD 流水线:构建状态/触发/日志等
|
||||
"webhook": webhook.Shortcuts(), // Webhook 管理:列表/创建/更新/删除等
|
||||
"wiki": wiki.Shortcuts(), // Wiki 文档管理:页面/列表/编辑等
|
||||
"snippet": snippet.Shortcuts(), // 代码片段(Snippet)管理:创建/列表/查看/删除等
|
||||
"collaborator": collaborator.Shortcuts(), // 协作者(Collaborator)管理:添加/移除/权限等
|
||||
"tag": tag.Shortcuts(), // Git 标签管理:列表/创建/删除等
|
||||
"milestone": milestone.Shortcuts(), // 里程碑(Milestone)管理:列表/创建/更新等
|
||||
"file": file.Shortcuts(), // 文件内容操作:读取/创建/更新/删除等
|
||||
"commit": commit.Shortcuts(), // 提交(Commit)操作:列表/详情/对比等
|
||||
"label": label.Shortcuts(), // 疑修标签(Label)管理:列表/创建/更新等
|
||||
"sshkey": sshkey.Shortcuts(), // SSH 公钥管理:列表/添加/删除等
|
||||
"util": util.Shortcuts(), // 工具命令:开源许可证/忽略模板/设置等
|
||||
"dataset": dataset.Shortcuts(), // 数据集(Dataset)管理:列表/创建等
|
||||
"template": template.Shortcuts(), // 项目模板管理:列表/创建等
|
||||
"attachment": attachment.Shortcuts(), // 文件附件操作:上传/下载等
|
||||
}
|
||||
}
|
||||
|
||||
// GetDescriptions 返回每个命令组的中文描述,用于交互式命令面板展示。
|
||||
func GetDescriptions() map[string]string {
|
||||
return map[string]string{
|
||||
"repo": "仓库管理", // 仓库的增删改查、Star、Watch 等
|
||||
"issue": "疑修 (Issue) 管理", // 疑修的创建、查看、关闭、评论等
|
||||
"pr": "合并请求 (PR) 管理", // 合并请求的创建、合并、评审等
|
||||
"release": "发行版管理", // 版本发布的创建与管理
|
||||
"branch": "分支管理", // 分支的创建、删除、保护等
|
||||
"org": "组织管理", // 组织信息与成员管理
|
||||
"user": "用户管理", // 用户信息与社交关系
|
||||
"search": "全局搜索", // 仓库/疑修/用户搜索
|
||||
"ci": "CI/CD 流水线", // 持续集成与部署
|
||||
"webhook": "Webhook 管理", // Web 钩子配置
|
||||
"wiki": "Wiki 文档", // 项目文档页面管理
|
||||
"snippet": "代码片段管理", // 代码片段的创建与分享
|
||||
"collaborator": "协作者管理", // 仓库协作者与权限
|
||||
"tag": "Git 标签管理", // 版本标签操作
|
||||
"milestone": "里程碑管理", // 版本里程碑管理
|
||||
"file": "文件内容操作", // 仓库文件的读写
|
||||
"commit": "提交管理", // 提交记录查看
|
||||
"label": "疑修标签管理", // 标签的创建与管理
|
||||
"sshkey": "SSH 公钥管理", // SSH 公钥配置
|
||||
"util": "工具命令", // 许可证、忽略模板等工具
|
||||
"dataset": "数据集管理", // 数据集管理
|
||||
"template": "项目模板管理", // 项目模板管理
|
||||
"attachment": "文件附件操作", // 附件上传与下载
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,32 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有发行版
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List releases",
|
||||
Description: "列出仓库的所有发行版",
|
||||
Long: `List all releases in the current repository.
|
||||
|
||||
Shows release tag, name, prerelease status, and creation date.
|
||||
Use --page and --limit for pagination.
|
||||
Use --prerelease-only to show only pre-release versions.
|
||||
Use --latest to show only the most recent release.`,
|
||||
Example: ` # List releases
|
||||
gitlink release +list
|
||||
|
||||
# Show 50 releases per page
|
||||
gitlink release +list --limit 50
|
||||
|
||||
# Show only pre-release versions
|
||||
gitlink release +list --prerelease-only
|
||||
|
||||
# Show only the latest release
|
||||
gitlink release +list --latest`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "prerelease-only", Usage: "Show only pre-release versions", Bool: true, Default: "false"},
|
||||
{Name: "latest", Usage: "Show only the latest release", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -28,17 +48,38 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] API 不支持"只看预发布版本"的过滤参数,改为客户端过滤
|
||||
// 拿到全量数据后遍历,只保留 prerelease=true 的条目
|
||||
if ctx.Arg("prerelease-only") == "true" {
|
||||
filterReleasesByPrerelease(env)
|
||||
}
|
||||
// [P2 参数补全] --latest: 拿到列表后只保留第一条(最新)
|
||||
if ctx.Arg("latest") == "true" {
|
||||
keepOnlyLatestRelease(env)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建发行版
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a release",
|
||||
Description: "创建发行版",
|
||||
Long: `Create a new release for the current repository.
|
||||
|
||||
A release is associated with a Git tag. If the tag does not exist,
|
||||
it will be created on the target branch. Use --body for release notes
|
||||
and --prerelease to mark it as a pre-release version.`,
|
||||
Example: ` # Create a release
|
||||
gitlink release +create --tag v1.0.0 --name "Version 1.0.0"
|
||||
|
||||
# Create a prerelease with notes targeting a specific branch
|
||||
gitlink release +create --tag v2.0.0-beta --name "v2.0 Beta" \
|
||||
--body "Beta release for testing" --target develop --prerelease true`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Release notes"},
|
||||
{Name: "target", Usage: "Target branch", Default: "master"},
|
||||
{Name: "target", Usage: "Target branch (default: repository default branch)"},
|
||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
|
|
@ -54,8 +95,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
// [P1 Bug#2] 原代码: payload["target_commitish"] = "master"(硬编码)
|
||||
// 修复: 用户未指定 --target 时,使用 GetDefaultBranch() 动态查询
|
||||
if t := ctx.Arg("target"); t != "" {
|
||||
payload["target_commitish"] = t
|
||||
payload["target_commitish"] = t // 用户显式指定了目标分支
|
||||
} else {
|
||||
payload["target_commitish"] = common.GetDefaultBranch(ctx) // 动态查询默认分支
|
||||
}
|
||||
if ctx.Arg("prerelease") == "true" {
|
||||
payload["prerelease"] = true
|
||||
|
|
@ -67,9 +112,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看发行版的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View release details",
|
||||
Description: "查看发行版的详细信息",
|
||||
Long: `Show detailed information about a specific release.
|
||||
|
||||
Provide the release ID or tag name to view its details.`,
|
||||
Example: ` # View a release by ID
|
||||
gitlink release +view --id 42
|
||||
|
||||
# View a release by tag
|
||||
gitlink release +view --id v1.0.0`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
||||
},
|
||||
|
|
@ -85,9 +139,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除发行版
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a release",
|
||||
Description: "删除发行版",
|
||||
Long: `Delete a release from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a release
|
||||
gitlink release +delete --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
|
|
@ -96,6 +156,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete release #%s", id),
|
||||
); 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.
|
||||
|
|
@ -104,16 +169,120 @@ func Shortcuts() []*common.Shortcut {
|
|||
if viewErr != nil {
|
||||
// Release no longer exists — delete actually succeeded
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
"message": "Release deleted successfully",
|
||||
}, nil))
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
"message": "Release deleted successfully",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
// +edit:获取发行版编辑页详情
|
||||
{
|
||||
Name: "edit",
|
||||
Description: "获取发行版编辑页详情",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/releases/%s/edit", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新发行版
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新发行版",
|
||||
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 body"},
|
||||
{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")
|
||||
payload := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
payload["name"] = n
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
if p := ctx.Arg("prerelease"); p != "" {
|
||||
payload["prerelease"] = p == "true"
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --body, or --prerelease is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/releases/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// filterReleasesByPrerelease removes non-prerelease entries from the envelope data.
|
||||
func filterReleasesByPrerelease(env *output.Envelope) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
releases, ok := data["releases"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isPrerelease(m) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
data["releases"] = filtered
|
||||
}
|
||||
|
||||
// keepOnlyLatestRelease keeps only the first (most recent) release.
|
||||
func keepOnlyLatestRelease(env *output.Envelope) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
releases, ok := data["releases"].([]interface{})
|
||||
if !ok || len(releases) == 0 {
|
||||
return
|
||||
}
|
||||
data["releases"] = []interface{}{releases[0]}
|
||||
}
|
||||
|
||||
// isPrerelease checks whether a release map has prerelease=true.
|
||||
func isPrerelease(m map[string]interface{}) bool {
|
||||
v, ok := m["prerelease"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
default:
|
||||
return fmt.Sprint(val) == "true"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,33 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前用户或指定用户的仓库(支持按类别筛选:manage/mirror/sync/fork/all)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
Description: "列出用户或组织的仓库",
|
||||
Long: `List repositories visible to the current user.
|
||||
|
||||
By default shows repositories you manage. Use --user to list
|
||||
another user's repositories. Use --category to filter by type
|
||||
(manage, mirror, sync, fork, or all).`,
|
||||
Example: ` # List your managed repositories
|
||||
gitlink repo +list
|
||||
|
||||
# List all repositories for a specific user
|
||||
gitlink repo +list --user zhangsan --category all
|
||||
|
||||
# List forked repositories with pagination
|
||||
gitlink repo +list --category fork --page 2 --limit 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
|
||||
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
|
||||
|
|
@ -38,9 +54,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:显示当前仓库的详情(名称/描述/可见性/默认分支等)
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show repository details",
|
||||
Description: "显示仓库详情",
|
||||
Long: `Display detailed information about the current repository.
|
||||
|
||||
Shows name, description, visibility, default branch, and other metadata.`,
|
||||
Example: ` # Show details of the current repository
|
||||
gitlink repo +info`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -52,9 +74,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前账户下创建新仓库(需 --name,可选 --description 与 --private)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new repository",
|
||||
Description: "创建新仓库",
|
||||
Long: `Create a new repository under your account.
|
||||
|
||||
The repository name is required. Optionally provide a description
|
||||
and set visibility to private.`,
|
||||
Example: ` # Create a public repository
|
||||
gitlink repo +create --name my-project
|
||||
|
||||
# Create a private repository with a description
|
||||
gitlink repo +create --name my-project --description "A cool project" --private true`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
|
|
@ -94,9 +126,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +fork:将当前仓库 fork 到自己的命名空间下
|
||||
{
|
||||
Name: "fork",
|
||||
Description: "Fork a repository",
|
||||
Description: "Fork 仓库",
|
||||
Long: `Fork the current repository into your account.
|
||||
|
||||
This creates a copy of the repository under your namespace.`,
|
||||
Example: ` # Fork the current repository
|
||||
gitlink repo +fork`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -108,13 +146,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:永久删除当前仓库(不可逆,删除前需确认)
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a repository",
|
||||
Description: "删除仓库",
|
||||
Long: `Delete the current repository permanently.
|
||||
|
||||
This action is irreversible and removes all data including issues,
|
||||
pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete the current repository
|
||||
gitlink repo +delete`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete repository %s/%s", ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -122,5 +172,558 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Settings/Info group ---
|
||||
|
||||
// +update:更新当前仓库的设置(名称/描述/可见性/网站)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "New repository name"},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
{Name: "private", Usage: "Make repository private (true/false)"},
|
||||
{Name: "website", Short: "w", Usage: "Repository website URL"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if v := ctx.Arg("name"); v != "" {
|
||||
body["name"] = v
|
||||
}
|
||||
if v := ctx.Arg("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
if v := ctx.Arg("private"); v != "" {
|
||||
body["private"] = v == "true"
|
||||
}
|
||||
if v := ctx.Arg("website"); v != "" {
|
||||
body["website"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +about:显示仓库的关于页面
|
||||
{
|
||||
Name: "about",
|
||||
Description: "显示仓库关于页面",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/about", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +menu:列出仓库的导航菜单
|
||||
{
|
||||
Name: "menu",
|
||||
Description: "列出仓库导航菜单",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/menu_list", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-get:获取仓库的导航单元设置
|
||||
{
|
||||
Name: "units-get",
|
||||
Description: "获取仓库导航单元设置",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-set:更新仓库的导航单元设置(--units 传 JSON 数组)
|
||||
{
|
||||
Name: "units-set",
|
||||
Description: "更新仓库导航单元设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "units", Short: "u", Usage: "JSON array of unit settings", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
unitsRaw, err := ctx.RequireArg("units")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var units []interface{}
|
||||
if err := json.Unmarshal([]byte(unitsRaw), &units); err != nil {
|
||||
return fmt.Errorf("invalid JSON for --units: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"units": units,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +edit-detail:获取仓库编辑页面的详情
|
||||
{
|
||||
Name: "edit-detail",
|
||||
Description: "获取仓库编辑详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +simple:获取仓库的简化详情信息
|
||||
{
|
||||
Name: "simple",
|
||||
Description: "获取仓库简化详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Stats group ---
|
||||
|
||||
// +code-stats:获取仓库的代码统计信息
|
||||
{
|
||||
Name: "code-stats",
|
||||
Description: "获取仓库代码统计",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/code_stats", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +languages:获取仓库的编程语言占比
|
||||
{
|
||||
Name: "languages",
|
||||
Description: "获取仓库语言占比",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors:列出仓库的贡献者
|
||||
{
|
||||
Name: "contributors",
|
||||
Description: "列出仓库贡献者",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors-stat:获取贡献者的代码统计信息
|
||||
{
|
||||
Name: "contributors-stat",
|
||||
Description: "获取贡献者代码统计",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/contributors/stat", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Social/Interaction group ---
|
||||
|
||||
// +recommend:获取推荐项目列表
|
||||
{
|
||||
Name: "recommend",
|
||||
Description: "获取推荐项目",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +star:点赞(加星)指定项目
|
||||
{
|
||||
Name: "star",
|
||||
Description: "点赞项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/projects/%s/praise_tread/like", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unstar:取消点赞(取消加星)指定项目
|
||||
{
|
||||
Name: "unstar",
|
||||
Description: "取消点赞项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/projects/%s/praise_tread/unlike", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watch:关注指定项目
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "关注项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid project id: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"project_id": idInt,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/watchers/follow", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unwatch:取消关注指定项目
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "取消关注项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/watchers/unfollow?project_id=%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stargazers:列出对当前仓库加星的用户
|
||||
{
|
||||
Name: "stargazers",
|
||||
Description: "列出仓库的加星用户",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/stargazers", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watchers:列出关注当前仓库的用户
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "列出仓库的关注者",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/watchers", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Transfer group ---
|
||||
|
||||
// +transfer:将仓库转移给其他所有者或组织
|
||||
{
|
||||
Name: "transfer",
|
||||
Description: "转移仓库所有权",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Target owner/organization", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner": owner,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +cancel-transfer:取消待处理的仓库转移申请
|
||||
{
|
||||
Name: "cancel-transfer",
|
||||
Description: "取消待处理的仓库转移",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +transfer-orgs:列出可用于转移目标组织
|
||||
{
|
||||
Name: "transfer-orgs",
|
||||
Description: "列出可转移的组织",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +invite-link:获取当前仓库的邀请链接
|
||||
{
|
||||
Name: "invite-link",
|
||||
Description: "获取仓库邀请链接",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_invite_links/current_link", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- L3 Operations: Join/Quit/Mirror/Topics ---
|
||||
|
||||
// +join:申请加入指定项目(可指定期望角色)
|
||||
{
|
||||
Name: "join",
|
||||
Description: "申请加入项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Desired role"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"project_id": id,
|
||||
}
|
||||
if role := ctx.Arg("role"); role != "" {
|
||||
body["role"] = role
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/applied_projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +quit:退出当前项目
|
||||
{
|
||||
Name: "quit",
|
||||
Description: "退出项目",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/quit", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +migrate:创建镜像项目(从上游 URL 镜像克隆)
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "创建镜像项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "clone-url", Short: "u", Usage: "Upstream clone URL", Required: true},
|
||||
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cloneURL, err := ctx.RequireArg("clone-url")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"repository_name": name,
|
||||
"clone_addr": cloneURL,
|
||||
}
|
||||
if ctx.Arg("private") == "true" {
|
||||
body["private"] = true
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/projects/migrate", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sync-mirror:同步镜像仓库(从上游拉取更新)
|
||||
{
|
||||
Name: "sync-mirror",
|
||||
Description: "同步镜像仓库",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Repository ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/repositories/%s/sync_mirror", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +topics:列出项目话题
|
||||
{
|
||||
Name: "topics",
|
||||
Description: "列出项目话题",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-topic:创建项目话题(需 --name)
|
||||
{
|
||||
Name: "create-topic",
|
||||
Description: "创建项目话题",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1/project_topics", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-topic:删除指定项目话题
|
||||
{
|
||||
Name: "delete-topic",
|
||||
Description: "删除项目话题",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Topic ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/project_topics/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findRepoShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findRepoShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Required tests ---
|
||||
|
||||
func TestRepoUpdateSendsPATCH(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "new-name",
|
||||
"description": "updated desc",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "update", map[string]string{
|
||||
"name": "new-name",
|
||||
"description": "updated desc",
|
||||
"private": "true",
|
||||
"website": "https://example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "PATCH")
|
||||
assertEqual(t, calledPath, "/owner/repo.json")
|
||||
assertEqual(t, payload["name"], "new-name")
|
||||
assertEqual(t, payload["description"], "updated desc")
|
||||
assertEqual(t, payload["private"], true)
|
||||
assertEqual(t, payload["website"], "https://example.com")
|
||||
}
|
||||
|
||||
func TestRepoAboutCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "test-repo",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "about", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("about shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "GET")
|
||||
assertEqual(t, calledPath, "/owner/repo/about.json")
|
||||
}
|
||||
|
||||
func TestRepoStarCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "star", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("star shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/projects/42/praise_tread/like.json")
|
||||
}
|
||||
|
||||
func TestRepoLanguagesCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"Go": float64(80),
|
||||
"Java": float64(20),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "languages", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("languages shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "GET")
|
||||
assertEqual(t, calledPath, "/owner/repo/languages.json")
|
||||
}
|
||||
|
||||
// --- Additional coverage tests ---
|
||||
|
||||
func TestRepoMenuCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"menus": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "menu", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("menu shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/menu_list.json")
|
||||
}
|
||||
|
||||
func TestRepoUnitsGetCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"units": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-get", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("units-get shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/project_units.json")
|
||||
}
|
||||
|
||||
func TestRepoUnitsSetPostsCorrectPayload(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-set", map[string]string{
|
||||
"units": `[{"type":"code","enabled":true}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("units-set shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/project_units.json")
|
||||
// The units JSON string should be unmarshaled into a slice
|
||||
units, ok := body["units"].([]interface{})
|
||||
if !ok || len(units) == 0 {
|
||||
t.Fatalf("expected units to be a non-empty array, got %v", body["units"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCodeStatsUsesV1Endpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"additions": float64(100),
|
||||
"deletions": float64(20),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "code-stats", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("code-stats shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/code_stats.json")
|
||||
}
|
||||
|
||||
func TestRepoContributorsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"contributors": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "contributors", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("contributors shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/contributors.json")
|
||||
}
|
||||
|
||||
func TestRepoContributorsStatUsesV1Endpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"contributors": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "contributors-stat", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("contributors-stat shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/contributors/stat.json")
|
||||
}
|
||||
|
||||
func TestRepoRecommendCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"projects": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "recommend", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("recommend shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/projects/recommend.json")
|
||||
}
|
||||
|
||||
func TestRepoUnstarCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "unstar", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unstar shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "DELETE")
|
||||
assertEqual(t, calledPath, "/projects/42/praise_tread/unlike.json")
|
||||
}
|
||||
|
||||
func TestRepoWatchCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watch", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("watch shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/watchers/follow.json")
|
||||
assertEqual(t, body["project_id"], float64(42))
|
||||
}
|
||||
|
||||
func TestRepoUnwatchCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "unwatch", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unwatch shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "DELETE")
|
||||
assertEqual(t, calledPath, "/watchers/unfollow.json")
|
||||
}
|
||||
|
||||
func TestRepoStargazersCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"stargazers": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "stargazers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("stargazers shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/stargazers.json")
|
||||
}
|
||||
|
||||
func TestRepoWatchersCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"watchers": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watchers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("watchers shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/watchers.json")
|
||||
}
|
||||
|
||||
func TestRepoTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer", map[string]string{
|
||||
"owner": "new-org",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects.json")
|
||||
}
|
||||
|
||||
func TestRepoCancelTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "cancel-transfer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel-transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/cancel.json")
|
||||
}
|
||||
|
||||
func TestRepoTransferOrgsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"organizations": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer-orgs", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("transfer-orgs shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/organizations.json")
|
||||
}
|
||||
|
||||
func TestRepoInviteLinkCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"link": "https://example.com/invite/abc",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "invite-link", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("invite-link shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/project_invite_links/current_link.json")
|
||||
}
|
||||
|
||||
func TestRepoEditDetailCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "edit-detail", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("edit-detail shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/edit.json")
|
||||
}
|
||||
|
||||
func TestRepoSimpleCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "simple", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("simple shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/simple.json")
|
||||
}
|
||||
|
||||
func TestRepoStarRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "star", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWatchRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watch", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnitsSetRequiresUnits(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when units is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-set", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when units is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransferRequiresOwner(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when owner is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when owner is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,13 +8,33 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +repos:搜索仓库
|
||||
{
|
||||
Name: "repos",
|
||||
Description: "Search repositories",
|
||||
Description: "搜索仓库",
|
||||
Long: `Search for repositories on GitLink by keyword.
|
||||
|
||||
Returns matching repository names, owners, and descriptions.
|
||||
Use --language to filter by programming language, --sort and --order
|
||||
to control ordering, and --page and --limit for pagination.`,
|
||||
Example: ` # Search for repositories
|
||||
gitlink search +repos --keyword "cli"
|
||||
|
||||
# Show 50 results per page
|
||||
gitlink search +repos --keyword "cli" --limit 50
|
||||
|
||||
# Filter by language
|
||||
gitlink search +repos --keyword "web" --language Go
|
||||
|
||||
# Sort by update time descending
|
||||
gitlink search +repos --keyword "cli" --sort updated --order desc`,
|
||||
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"},
|
||||
{Name: "language", Usage: "Filter by programming language"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. stars, updated)"},
|
||||
{Name: "order", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
|
|
@ -22,6 +42,17 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
// [P2 参数补全] ⚠️ API 静默忽略:CLI 把参数发给 API,API 返回成功但未按参数过滤
|
||||
// 这是 GitLink 平台侧的问题,CLI 仍保留参数,将来平台修复后可直接生效
|
||||
if l := ctx.Arg("language"); l != "" {
|
||||
q.Set("language", l)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if o := ctx.Arg("order"); o != "" {
|
||||
q.Set("order", o)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -29,13 +60,29 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +users:搜索用户
|
||||
{
|
||||
Name: "users",
|
||||
Description: "Search users",
|
||||
Description: "搜索用户",
|
||||
Long: `Search for users on GitLink by keyword.
|
||||
|
||||
Returns matching user login names, display names, and profile summaries.
|
||||
Use --sort and --order to control ordering, and --page and --limit
|
||||
for pagination.`,
|
||||
Example: ` # Search for users
|
||||
gitlink search +users --keyword "zhang"
|
||||
|
||||
# Show 50 results per page
|
||||
gitlink search +users --keyword "zhang" --limit 50
|
||||
|
||||
# Sort results
|
||||
gitlink search +users --keyword "zhang" --sort created --order desc`,
|
||||
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"},
|
||||
{Name: "sort", Usage: "Sort field"},
|
||||
{Name: "order", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
|
|
@ -43,6 +90,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
// [P2 参数补全] ⚠️ API 静默忽略(同 +repos)
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if o := ctx.Arg("order"); o != "" {
|
||||
q.Set("order", o)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const snippetsDir = "snippets"
|
||||
|
||||
// Shortcuts returns code snippet management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库中的所有代码片段
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库中的所有代码片段",
|
||||
Run: runList,
|
||||
},
|
||||
// +view:查看代码片段内容
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看代码片段内容",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
// +create:新建代码片段
|
||||
{
|
||||
Name: "create",
|
||||
Description: "新建代码片段",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name (used as filename)", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "Snippet content (code)", Required: true},
|
||||
{Name: "language", Short: "l", Usage: "Programming language (e.g. go, python, js)"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +delete:删除代码片段
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除代码片段",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Override repoGitURLOverride in tests to point to a local repo.
|
||||
var repoGitURLOverride string
|
||||
|
||||
// repoGitURL returns the public Git URL for the main repository.
|
||||
func repoGitURL(owner, repo string) string {
|
||||
if repoGitURLOverride != "" {
|
||||
return repoGitURLOverride
|
||||
}
|
||||
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.git", owner, repo)
|
||||
}
|
||||
|
||||
// runGit runs a git command in the specified directory.
|
||||
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
var outBuf, errBuf strings.Builder
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
|
||||
// snippetFileName returns the full file path in the repo for a snippet.
|
||||
func snippetFileName(name string) string {
|
||||
if strings.Contains(name, ".") {
|
||||
return fmt.Sprintf("%s/%s", snippetsDir, name)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s.md", snippetsDir, name)
|
||||
}
|
||||
|
||||
// buildSnippetContent builds the markdown content for a snippet.
|
||||
func buildSnippetContent(language, code string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("```")
|
||||
b.WriteString(language)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(code)
|
||||
if !strings.HasSuffix(code, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// repoFilePath returns the API path prefix for repo file operations.
|
||||
func repoFilePath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func runList(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clone repo to temp dir (read-only for public repos)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
// List files in the snippets directory
|
||||
snippetPath := filepath.Join(tmpDir, snippetsDir)
|
||||
entries, err := os.ReadDir(snippetPath)
|
||||
if err != nil {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"snippets": []interface{}{},
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
snippets := make([]map[string]interface{}, 0)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
snippets = append(snippets, map[string]interface{}{
|
||||
"name": e.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"snippets": snippets,
|
||||
"total": len(snippets),
|
||||
})
|
||||
}
|
||||
|
||||
func runView(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clone repo to temp dir (read-only for public repos)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, filePath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("snippet %q not found", name)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"content": string(content),
|
||||
})
|
||||
}
|
||||
|
||||
func runCreate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
language := ctx.Arg("language")
|
||||
message := ctx.Arg("message")
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Add snippet %q", name)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
snippetContent := buildSnippetContent(language, content)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(snippetContent)),
|
||||
"message": message,
|
||||
"branch": ctx.Arg("branch"),
|
||||
}
|
||||
|
||||
if _, err = ctx.CallAPI("POST", repoFilePath(ctx)+"/create_file", payload); err != nil {
|
||||
return fmt.Errorf("create snippet: %w", err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"message": "Snippet created",
|
||||
})
|
||||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := ctx.Arg("message")
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Delete snippet %q", name)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
|
||||
// Auto-fetch file SHA using Git (public repo)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
// Get blob SHA of the file
|
||||
shaOut, _, err := runGit(tmpDir, "hash-object", filePath)
|
||||
sha := strings.TrimSpace(shaOut)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
"branch": ctx.Arg("branch"),
|
||||
"sha": sha,
|
||||
}
|
||||
|
||||
if _, err = ctx.CallAPI("DELETE", repoFilePath(ctx)+"/delete_file", payload); err != nil {
|
||||
return fmt.Errorf("delete snippet: %w", err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"message": "Snippet deleted",
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Git repo test helpers ---
|
||||
|
||||
// setupSnippetRepo creates a bare Git repo with snippets, returns the repo path.
|
||||
func setupSnippetRepo(t *testing.T, files map[string]string) (bareRepo string, cleanup func()) {
|
||||
t.Helper()
|
||||
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-remote-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir remote: %v", err)
|
||||
}
|
||||
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
|
||||
if err != nil {
|
||||
os.RemoveAll(remoteDir)
|
||||
t.Fatalf("mkdir work: %v", err)
|
||||
}
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
|
||||
// Create snippets directory and files
|
||||
snippetPath := filepath.Join(workDir, "snippets")
|
||||
os.MkdirAll(snippetPath, 0755)
|
||||
if len(files) == 0 {
|
||||
os.WriteFile(filepath.Join(snippetPath, ".gitkeep"), []byte(""), 0644)
|
||||
}
|
||||
for name, content := range files {
|
||||
os.WriteFile(filepath.Join(snippetPath, name), []byte(content), 0644)
|
||||
}
|
||||
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "Add snippets")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
|
||||
os.RemoveAll(workDir)
|
||||
return remoteDir, func() { os.RemoveAll(remoteDir) }
|
||||
}
|
||||
|
||||
func runGitClean(dir string, args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP test helpers ---
|
||||
|
||||
func runSnippetShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findSnippetShortcut(t, name)
|
||||
var cli *client.Client
|
||||
if server != nil {
|
||||
cli = &client.Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
} else {
|
||||
cli = &client.Client{HTTP: http.DefaultClient, BaseURL: ""}
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: cli,
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findSnippetShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSnippetTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- Git-based tests (list, view) ---
|
||||
|
||||
func TestSnippetList(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
"sort.py": "def sort():\n pass\n",
|
||||
"readme.md": "# Snippets\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListEmpty(t *testing.T) {
|
||||
repo, cleanup := setupSnippetRepo(t, map[string]string{})
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list empty failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListNoSnippetsDir(t *testing.T) {
|
||||
// Create a bare repo with no snippets directory
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-empty-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(remoteDir)
|
||||
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "init")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
os.RemoveAll(workDir)
|
||||
|
||||
repoGitURLOverride = remoteDir
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list no snippets dir failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetView(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "hello.go"}); err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetViewNotFound(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "nope.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent snippet")
|
||||
}
|
||||
}
|
||||
|
||||
// --- API-based tests (create, delete) ---
|
||||
|
||||
func TestSnippetCreate(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "created"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{
|
||||
"name": "hello.go",
|
||||
"content": "package main\n\nfunc main() {}",
|
||||
"language": "go",
|
||||
"message": "Add hello.go snippet",
|
||||
"branch": "master",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/hello.go" {
|
||||
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
|
||||
}
|
||||
if payload["branch"] != "master" {
|
||||
t.Fatalf("branch = %q, want master", payload["branch"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreateWithoutExtension(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "created"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{
|
||||
"name": "myscript",
|
||||
"content": "#!/bin/bash\necho hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/myscript.md" {
|
||||
t.Fatalf("filepath = %q, want snippets/myscript.md", payload["filepath"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDelete(t *testing.T) {
|
||||
// Delete needs a Git repo to fetch SHA, plus an API server for the delete call
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
// API server for delete
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "delete", map[string]string{
|
||||
"name": "hello.go",
|
||||
"message": "Remove snippet",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/hello.go" {
|
||||
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
|
||||
}
|
||||
if payload["sha"] == "" {
|
||||
t.Fatal("sha should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDeleteDefaultMessage(t *testing.T) {
|
||||
files := map[string]string{"old.py": "print('old')\n"}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runSnippetShortcut(t, server, "delete", map[string]string{"name": "old.py"}); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if msg, ok := payload["message"].(string); !ok || !strings.Contains(msg, "old.py") {
|
||||
t.Fatalf("message = %q, should mention old.py", payload["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation tests ---
|
||||
|
||||
func TestSnippetCreateRejectsMissingName(t *testing.T) {
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{"content": "code"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreateRejectsMissingContent(t *testing.T) {
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{"name": "test.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --content is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetViewRejectsMissingName(t *testing.T) {
|
||||
err := findSnippetShortcut(t, "view").Run(&common.RuntimeContext{
|
||||
Owner: "test",
|
||||
Repo: "test",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDeleteRejectsMissingName(t *testing.T) {
|
||||
err := findSnippetShortcut(t, "delete").Run(&common.RuntimeContext{
|
||||
Owner: "test",
|
||||
Repo: "test",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests ---
|
||||
|
||||
func TestSnippetFileName(t *testing.T) {
|
||||
tests := []struct{ input, want string }{
|
||||
{"hello.go", "snippets/hello.go"},
|
||||
{"myscript.py", "snippets/myscript.py"},
|
||||
{"myscript", "snippets/myscript.md"},
|
||||
{"subdir/file.js", "snippets/subdir/file.js"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := snippetFileName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("snippetFileName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnippetContent(t *testing.T) {
|
||||
got := buildSnippetContent("go", "package main\nfunc main() {}")
|
||||
if !strings.Contains(got, "```go") {
|
||||
t.Errorf("expected ```go in output, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "package main") {
|
||||
t.Errorf("expected content in output, got: %s", got)
|
||||
}
|
||||
|
||||
got2 := buildSnippetContent("", "echo hello")
|
||||
if !strings.Contains(got2, "```\n") {
|
||||
t.Errorf("expected ``` without lang, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package sshkey
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前账号的 SSH 公钥
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出当前账号的 SSH 公钥",
|
||||
Long: `List your SSH public keys.
|
||||
|
||||
Shows all SSH public keys associated with your account, including
|
||||
key ID, title, fingerprint, and creation date.
|
||||
Supports pagination with --page and --limit flags.`,
|
||||
Example: ` # List all SSH keys
|
||||
gitlink sshkey +list
|
||||
|
||||
# List with pagination
|
||||
gitlink sshkey +list -p 2 -l 10`,
|
||||
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 {
|
||||
env, err := ctx.CallAPI("GET", "/public_keys", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:添加新的 SSH 公钥
|
||||
{
|
||||
Name: "create",
|
||||
Description: "添加新的 SSH 公钥",
|
||||
Long: `Add a new SSH public key.
|
||||
|
||||
Registers a new SSH public key to your account. The key content must
|
||||
be a valid public key in ssh-rsa or ssh-ed25519 format.`,
|
||||
Example: ` # Add an SSH key
|
||||
gitlink sshkey +create -t "My Laptop" -k "ssh-rsa AAAAB3..."`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Key title", Required: true},
|
||||
{Name: "key", Short: "k", Usage: "Public key content (ssh-rsa/ed25519 ...)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := ctx.RequireArg("key")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]string{
|
||||
"title": title,
|
||||
"key": key,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/public_keys", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定的 SSH 公钥
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除指定的 SSH 公钥",
|
||||
Long: `Delete an SSH public key.
|
||||
|
||||
Removes an SSH public key from your account by its ID. This action cannot be undone.`,
|
||||
Example: ` # Delete an SSH key by ID
|
||||
gitlink sshkey +delete -i 123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Key ID to delete", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/public_keys/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有标签",
|
||||
Long: `List all tags in the current repository.
|
||||
|
||||
Shows tag name, commit SHA, and creation date.
|
||||
Use --search to filter tags by name pattern.`,
|
||||
Example: ` # List all tags
|
||||
gitlink tag +list
|
||||
|
||||
# Search for tags matching a pattern
|
||||
gitlink tag +list --search "v1."
|
||||
|
||||
# Paginate through tags
|
||||
gitlink tag +list --page 2 --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "search", Short: "s", Usage: "Filter by tag name"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看标签的详细信息
|
||||
// 注意:GitLink API 不提供"获取单个标签"端点,这里通过列出全部标签后按名称过滤实现。
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看标签的详细信息",
|
||||
Long: `Show detailed information about a specific tag.
|
||||
|
||||
GitLink's API has no single-tag endpoint, so this lists all tags and
|
||||
returns the one whose name matches.`,
|
||||
Example: ` # View details of a tag
|
||||
gitlink tag +view --name v1.0.0`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Tag name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag := findTagByName(env, name)
|
||||
if tag == nil {
|
||||
return fmt.Errorf("标签 %q 不存在", name)
|
||||
}
|
||||
return ctx.OutputData(tag)
|
||||
},
|
||||
},
|
||||
// +delete:删除标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除标签",
|
||||
Long: `Delete a tag from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a tag
|
||||
gitlink tag +delete --name v0.9-beta`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Tag name to delete", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete tag %s", name),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findTagByName 在标签列表 envelope 中按名称查找单个标签。
|
||||
// GitLink API 没有"获取单个标签"端点,tag +view 需要先列出全部再过滤。
|
||||
// 找不到时返回 nil。
|
||||
func findTagByName(env *output.Envelope, name string) map[string]interface{} {
|
||||
if env == nil || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tags, ok := data["tags"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, t := range tags {
|
||||
tag, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, _ := tag["name"].(string); n == name {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// TestFindTagByName_Found 验证能从标签列表中按名称找到标签。
|
||||
func TestFindTagByName_Found(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0", "id": "abc"},
|
||||
map[string]interface{}{"name": "v2.0.0", "id": "def"},
|
||||
},
|
||||
"total_count": 2,
|
||||
},
|
||||
}
|
||||
tag := findTagByName(env, "v1.0.0")
|
||||
if tag == nil {
|
||||
t.Fatal("expected to find v1.0.0")
|
||||
}
|
||||
if tag["id"] != "abc" {
|
||||
t.Fatalf("expected id abc, got %v", tag["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NotFound 验证找不到时返回 nil。
|
||||
func TestFindTagByName_NotFound(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if tag := findTagByName(env, "v9.9.9"); tag != nil {
|
||||
t.Fatalf("expected nil for nonexistent tag, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NilEnvelope 验证边界情况。
|
||||
func TestFindTagByName_NilEnvelope(t *testing.T) {
|
||||
if tag := findTagByName(nil, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil envelope")
|
||||
}
|
||||
env := &output.Envelope{OK: true}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NoTagsKey 验证数据结构不含 tags 时返回 nil。
|
||||
func TestFindTagByName_NoTagsKey(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"other": "value"},
|
||||
}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatalf("expected nil when no tags key, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出某拥有者的项目模板
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出某拥有者的项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/project_templates", owner), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建项目模板
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Template name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Template description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/project_templates", owner), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看项目模板
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Repository name (optional, for repo-scoped templates)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var path string
|
||||
if repo := ctx.Arg("repo"); repo != "" {
|
||||
path = fmt.Sprintf("/v1/%s/%s/project_templates/%s", owner, repo, id)
|
||||
} else {
|
||||
path = fmt.Sprintf("/v1/%s/project_templates/%s", owner, id)
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新项目模板
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New template name"},
|
||||
{Name: "description", Short: "d", Usage: "New template description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除项目模板
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +me:显示当前登录用户信息
|
||||
{
|
||||
Name: "me",
|
||||
Description: "Show current authenticated user",
|
||||
Description: "显示当前登录用户",
|
||||
Long: `Show profile information for the currently authenticated user.
|
||||
|
||||
Displays login name, email, and account details based on the
|
||||
configured API token.`,
|
||||
Example: ` # Show current user profile
|
||||
gitlink user +me`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
|
|
@ -19,9 +28,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:显示指定用户的资料
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show user profile",
|
||||
Description: "显示用户资料",
|
||||
Long: `Show public profile information for a GitLink user.
|
||||
|
||||
Looks up the user by their login name and displays their
|
||||
username, bio, and other public profile fields.`,
|
||||
Example: ` # Show profile for a specific user
|
||||
gitlink user +info --login zhangsan`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -37,5 +53,763 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Message management ---
|
||||
// +messages:列出用户的私信
|
||||
{
|
||||
Name: "messages",
|
||||
Description: "列出用户私信",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "page", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page := ctx.Arg("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
limit := ctx.Arg("limit")
|
||||
if limit == "" {
|
||||
limit = "20"
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("page", page)
|
||||
query.Set("limit", limit)
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/messages", login), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-message:删除一条私信
|
||||
{
|
||||
Name: "delete-message",
|
||||
Description: "删除一条私信",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Message ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("id", id)
|
||||
env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/users/%s/messages", login), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-message:发送一条私信
|
||||
{
|
||||
Name: "create-message",
|
||||
Description: "发送一条私信",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "content", Usage: "Message content", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages", login), map[string]interface{}{
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +read-message:将私信标记为已读
|
||||
{
|
||||
Name: "read-message",
|
||||
Description: "将私信标记为已读",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Message ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages/read", login), map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +msg-settings:获取消息通知设置
|
||||
{
|
||||
Name: "msg-settings",
|
||||
Description: "获取消息通知设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/template_message_settings", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-msg-settings:更新消息通知设置
|
||||
{
|
||||
Name: "update-msg-settings",
|
||||
Description: "更新消息通知设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "settings", Usage: "Settings JSON array", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsStr, err := ctx.RequireArg("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var settings []interface{}
|
||||
if err := json.Unmarshal([]byte(settingsStr), &settings); err != nil {
|
||||
return fmt.Errorf("invalid settings JSON: %w", err)
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/template_message_settings/update_setting", login), map[string]interface{}{
|
||||
"setting": settings,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Pinned projects ---
|
||||
// +pinned-projects:列出置顶项目
|
||||
{
|
||||
Name: "pinned-projects",
|
||||
Description: "列出置顶项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/is_pinned_projects", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +pin-project:置顶一个项目
|
||||
{
|
||||
Name: "pin-project",
|
||||
Description: "置顶一个项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/is_pinned_projects/pin", login), map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reorder-pinned:调整置顶项目的顺序
|
||||
{
|
||||
Name: "reorder-pinned",
|
||||
Description: "调整置顶项目的顺序",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Pinned project ID", Required: true},
|
||||
{Name: "position", Usage: "New position", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
position, err := ctx.RequireArg("position")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s/is_pinned_projects/%s", login, id), map[string]interface{}{
|
||||
"position": position,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Statistics ---
|
||||
// +activity:获取用户活动统计
|
||||
{
|
||||
Name: "activity",
|
||||
Description: "获取用户活动统计",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +headmap:获取用户活跃热力图数据
|
||||
{
|
||||
Name: "headmap",
|
||||
Description: "获取用户活跃热力图数据",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/headmaps", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +develop-stats:获取用户开发统计
|
||||
{
|
||||
Name: "develop-stats",
|
||||
Description: "获取用户开发统计",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/develop", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +role-stats:获取用户角色统计
|
||||
{
|
||||
Name: "role-stats",
|
||||
Description: "获取用户角色统计",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/role", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +major-stats:获取用户专业统计
|
||||
{
|
||||
Name: "major-stats",
|
||||
Description: "获取用户专业统计",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/major", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Transfer / Apply ---
|
||||
// +applied-transfers:列出待处理的仓库转让申请
|
||||
{
|
||||
Name: "applied-transfers",
|
||||
Description: "列出待处理的仓库转让申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_transfer_projects", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +accept-transfer:接受仓库转让申请
|
||||
{
|
||||
Name: "accept-transfer",
|
||||
Description: "接受仓库转让申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Transfer ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/accept", login, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +refuse-transfer:拒绝仓库转让申请
|
||||
{
|
||||
Name: "refuse-transfer",
|
||||
Description: "拒绝仓库转让申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Transfer ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/refuse", login, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +applied-projects:列出待处理的仓库加入申请
|
||||
{
|
||||
Name: "applied-projects",
|
||||
Description: "列出待处理的仓库加入申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_projects", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +accept-join:接受仓库加入申请
|
||||
{
|
||||
Name: "accept-join",
|
||||
Description: "接受仓库加入申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Application ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/accept", login, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +refuse-join:拒绝仓库加入申请
|
||||
{
|
||||
Name: "refuse-join",
|
||||
Description: "拒绝仓库加入申请",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Application ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/refuse", login, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Feedback / Update ---
|
||||
// +feedback:提交反馈意见
|
||||
{
|
||||
Name: "feedback",
|
||||
Description: "提交反馈意见",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "content", Usage: "Feedback content", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/feedbacks", login), map[string]interface{}{
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新用户资料
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新用户资料",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "nickname", Usage: "New nickname"},
|
||||
{Name: "description", Usage: "New description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if v := ctx.Arg("nickname"); v != "" {
|
||||
body["nickname"] = v
|
||||
}
|
||||
if v := ctx.Arg("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s", login), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Account settings ---
|
||||
// +change-password:修改账户密码
|
||||
{
|
||||
Name: "change-password",
|
||||
Description: "修改账户密码",
|
||||
Flags: []common.Flag{
|
||||
{Name: "old-password", Usage: "Current password", Required: true},
|
||||
{Name: "new-password", Usage: "New password", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
oldPwd, err := ctx.RequireArg("old-password")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPwd, err := ctx.RequireArg("new-password")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/accounts/change_password", map[string]interface{}{
|
||||
"old_password": oldPwd,
|
||||
"new_password": newPwd,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-email:更新邮箱地址
|
||||
{
|
||||
Name: "update-email",
|
||||
Description: "更新邮箱地址",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "New email address", Required: true},
|
||||
{Name: "code", Usage: "Verification code"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email, err := ctx.RequireArg("email")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"email": email,
|
||||
}
|
||||
if v := ctx.Arg("code"); v != "" {
|
||||
body["code"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_email", login), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-phone:更新手机号码
|
||||
{
|
||||
Name: "update-phone",
|
||||
Description: "更新手机号码",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "phone", Usage: "New phone number", Required: true},
|
||||
{Name: "code", Usage: "Verification code"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phone, err := ctx.RequireArg("phone")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"phone": phone,
|
||||
}
|
||||
if v := ctx.Arg("code"); v != "" {
|
||||
body["code"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_phone", login), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +send-verify:发送邮箱验证码
|
||||
{
|
||||
Name: "send-verify",
|
||||
Description: "发送邮箱验证码",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "Email address"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
if v := ctx.Arg("email"); v != "" {
|
||||
query.Set("email", v)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/send_email_vefify_code", login), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-verify:校验邮箱验证码
|
||||
{
|
||||
Name: "check-verify",
|
||||
Description: "校验邮箱验证码",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "code", Usage: "Verification code", Required: true},
|
||||
{Name: "email", Usage: "Email address"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, err := ctx.RequireArg("code")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"code": code,
|
||||
}
|
||||
if v := ctx.Arg("email"); v != "" {
|
||||
body["email"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email_verify_code", login), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-password:校验密码
|
||||
{
|
||||
Name: "check-password",
|
||||
Description: "校验密码",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "password", Usage: "Password to verify", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password, err := ctx.RequireArg("password")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_password", login), map[string]interface{}{
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-email:校验邮箱
|
||||
{
|
||||
Name: "check-email",
|
||||
Description: "校验邮箱",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "Email to verify", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email, err := ctx.RequireArg("email")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email", login), map[string]interface{}{
|
||||
"email": email,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +can-delete:检查账户是否可被注销
|
||||
{
|
||||
Name: "can-delete",
|
||||
Description: "检查账户是否可被注销",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_user_can_delete", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:注销(停用)用户账户
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "注销(停用)用户账户",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findUserShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Required tests ---
|
||||
|
||||
func TestUserMessagesListsMessages(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"messages": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "messages", map[string]string{
|
||||
"login": "testuser",
|
||||
"page": "1",
|
||||
"limit": "20",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("messages shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "GET")
|
||||
assertEqual(t, calledPath, "/users/testuser/messages.json")
|
||||
}
|
||||
|
||||
func TestUserFeedbackSendsPOST(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "feedback", map[string]string{
|
||||
"login": "testuser",
|
||||
"content": "Great platform!",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("feedback shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/v1/testuser/feedbacks.json")
|
||||
assertEqual(t, payload["content"], "Great platform!")
|
||||
}
|
||||
|
||||
// --- Additional coverage tests ---
|
||||
|
||||
func TestUserDeleteMessageCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "delete-message", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-message shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "DELETE")
|
||||
assertEqual(t, calledPath, "/users/testuser/messages.json")
|
||||
}
|
||||
|
||||
func TestUserCreateMessageSendsPOST(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "create-message", map[string]string{
|
||||
"login": "testuser",
|
||||
"content": "Hello!",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-message shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/messages.json")
|
||||
assertEqual(t, payload["content"], "Hello!")
|
||||
}
|
||||
|
||||
func TestUserReadMessageCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "read-message", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read-message shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/messages/read.json")
|
||||
}
|
||||
|
||||
func TestUserMsgSettingsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"settings": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "msg-settings", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("msg-settings shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/template_message_settings.json")
|
||||
}
|
||||
|
||||
func TestUserUpdateMsgSettingsSendsPOST(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "update-msg-settings", map[string]string{
|
||||
"login": "testuser",
|
||||
"settings": `[{"id":1,"enabled":true}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-msg-settings shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/template_message_settings/update_setting.json")
|
||||
settings, ok := payload["setting"].([]interface{})
|
||||
if !ok || len(settings) == 0 {
|
||||
t.Fatalf("expected setting to be a non-empty array, got %v", payload["setting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPinnedProjectsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "pinned-projects", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pinned-projects shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects.json")
|
||||
}
|
||||
|
||||
func TestUserPinProjectCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "pin-project", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pin-project shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/pin.json")
|
||||
}
|
||||
|
||||
func TestUserReorderPinnedCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "reorder-pinned", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "10",
|
||||
"position": "1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reorder-pinned shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "PUT")
|
||||
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/10.json")
|
||||
}
|
||||
|
||||
func TestUserActivityCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"activity": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "activity", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("activity shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/statistics/activity.json")
|
||||
}
|
||||
|
||||
func TestUserHeadmapCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"headmaps": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "headmap", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("headmap shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/headmaps.json")
|
||||
}
|
||||
|
||||
func TestUserDevelopStatsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"develop": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "develop-stats", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("develop-stats shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/statistics/develop.json")
|
||||
}
|
||||
|
||||
func TestUserRoleStatsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"role": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "role-stats", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("role-stats shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/statistics/role.json")
|
||||
}
|
||||
|
||||
func TestUserMajorStatsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"major": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "major-stats", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("major-stats shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/statistics/major.json")
|
||||
}
|
||||
|
||||
func TestUserAppliedTransfersCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "applied-transfers", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("applied-transfers shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects.json")
|
||||
}
|
||||
|
||||
func TestUserAcceptTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "accept-transfer", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accept-transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/accept.json")
|
||||
}
|
||||
|
||||
func TestUserRefuseTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "refuse-transfer", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refuse-transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/refuse.json")
|
||||
}
|
||||
|
||||
func TestUserAppliedProjectsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "applied-projects", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("applied-projects shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_projects.json")
|
||||
}
|
||||
|
||||
func TestUserAcceptJoinCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "accept-join", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "8",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accept-join shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/accept.json")
|
||||
}
|
||||
|
||||
func TestUserRefuseJoinCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "refuse-join", map[string]string{
|
||||
"login": "testuser",
|
||||
"id": "8",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refuse-join shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/refuse.json")
|
||||
}
|
||||
|
||||
func TestUserUpdateSendsPUT(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "update", map[string]string{
|
||||
"login": "testuser",
|
||||
"nickname": "NewNick",
|
||||
"description": "Updated bio",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "PUT")
|
||||
assertEqual(t, calledPath, "/users/testuser.json")
|
||||
assertEqual(t, payload["nickname"], "NewNick")
|
||||
assertEqual(t, payload["description"], "Updated bio")
|
||||
}
|
||||
|
||||
// --- Required-flag validation tests ---
|
||||
|
||||
func TestUserMessagesRequiresLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when login is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "messages", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when login is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFeedbackRequiresContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when content is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "feedback", map[string]string{
|
||||
"login": "testuser",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when content is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +licenses:列出可用的开源许可证模板
|
||||
{
|
||||
Name: "licenses",
|
||||
Description: "列出可用的开源许可证模板",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/licenses", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +ignores:列出可用的 .gitignore 模板
|
||||
{
|
||||
Name: "ignores",
|
||||
Description: "列出可用的 .gitignore 模板",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/ignores", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +platform-msg-settings:查看平台消息通知设置
|
||||
{
|
||||
Name: "platform-msg-settings",
|
||||
Description: "查看平台消息通知设置",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var allowedWebhookTypes = map[string]bool{
|
||||
"gitea": true, "slack": true, "discord": true, "dingtalk": true, "telegram": true,
|
||||
"msteams": true, "feishu": true, "matrix": true, "jianmu": true, "softbot": true,
|
||||
}
|
||||
|
||||
var allowedWebhookContentTypes = map[string]bool{"json": true, "form": true}
|
||||
var allowedWebhookMethods = map[string]bool{"GET": true, "POST": true}
|
||||
|
||||
var allowedWebhookEvents = map[string]bool{
|
||||
"push": true, "create": true, "delete": true,
|
||||
"issues_only": true, "issue_assign": true, "issue_label": true, "issue_comment": true,
|
||||
"pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true,
|
||||
}
|
||||
|
||||
// Shortcuts returns webhook management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有 Web 钩子
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有 Web 钩子",
|
||||
Long: `List repository webhooks.
|
||||
|
||||
Returns all webhooks configured for the current repository.`,
|
||||
Example: ` # List all webhooks
|
||||
gitlink webhook +list`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", webhookPath(ctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看 Web 钩子的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看 Web 钩子的详细信息",
|
||||
Long: `View webhook details.
|
||||
|
||||
Shows the full configuration of a specific webhook including
|
||||
URL, events, type, and active status.`,
|
||||
Example: ` # View a webhook
|
||||
gitlink webhook +view --id 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建仓库 Web 钩子
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建仓库 Web 钩子",
|
||||
Long: `Create a repository webhook.
|
||||
|
||||
Creates a new webhook that sends HTTP requests to the specified URL
|
||||
when certain events occur. Supported types: gitea, slack, discord,
|
||||
dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`,
|
||||
Example: ` # Create a push webhook
|
||||
gitlink webhook +create --url https://example.com/hook --events push
|
||||
|
||||
# Create a webhook for multiple events
|
||||
gitlink webhook +create --url https://example.com/hook --events push,issues_only --secret mysecret`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
|
||||
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
|
||||
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"},
|
||||
{Name: "content-type", Usage: "Payload content type: json or form", Default: "json"},
|
||||
{Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"},
|
||||
{Name: "secret", Short: "s", Usage: "Webhook secret"},
|
||||
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"},
|
||||
{Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"},
|
||||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +update:更新仓库 Web 钩子,未指定的字段尽可能保留原值
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库 Web 钩子,未指定的字段尽可能保留原值",
|
||||
Long: `Update a repository webhook while preserving unspecified fields when available.
|
||||
|
||||
Fetches the current webhook configuration, merges changes from the provided
|
||||
flags, and updates the webhook. Fields not specified are preserved from the
|
||||
current configuration.`,
|
||||
Example: ` # Update a webhook URL
|
||||
gitlink webhook +update --id 10 --url https://new.example.com/hook
|
||||
|
||||
# Update webhook events
|
||||
gitlink webhook +update --id 10 --events push,pull_request_only`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "url", Short: "u", Usage: "Webhook target URL"},
|
||||
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"},
|
||||
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"},
|
||||
{Name: "content-type", Usage: "Payload content type: json or form"},
|
||||
{Name: "http-method", Usage: "HTTP method: POST or GET"},
|
||||
{Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."},
|
||||
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"},
|
||||
{Name: "active", Usage: "Whether the webhook is active: true or false"},
|
||||
},
|
||||
Run: runUpdate,
|
||||
},
|
||||
// +delete:删除仓库 Web 钩子
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库 Web 钩子",
|
||||
Long: `Delete a repository webhook.
|
||||
|
||||
Permanently removes the webhook from the repository.`,
|
||||
Example: ` # Delete a webhook
|
||||
gitlink webhook +delete --id 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", webhookItemPath(ctx, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +test:触发 Web 钩子的测试投递
|
||||
{
|
||||
Name: "test",
|
||||
Description: "触发 Web 钩子的测试投递",
|
||||
Long: `Trigger a test delivery for a webhook.
|
||||
|
||||
Sends a test payload to the webhook URL to verify the configuration.`,
|
||||
Example: ` # Test a webhook
|
||||
gitlink webhook +test --id 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/tests", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +tasks:列出 Web 钩子的投递任务
|
||||
{
|
||||
Name: "tasks",
|
||||
Description: "列出 Web 钩子的投递任务",
|
||||
Long: `List webhook delivery tasks.
|
||||
|
||||
Returns the history of webhook deliveries including status,
|
||||
response code, and timing information.`,
|
||||
Example: ` # List delivery tasks for a webhook
|
||||
gitlink webhook +tasks --id 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +failed:列出投递失败的 Web 钩子任务
|
||||
{
|
||||
Name: "failed",
|
||||
Description: "列出投递失败的 Web 钩子任务",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
tasks, ok := raw["hooktasks"].([]interface{})
|
||||
if !ok {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"hooktasks": []interface{}{},
|
||||
"total_count": 0,
|
||||
})
|
||||
}
|
||||
limit := 0
|
||||
if limitStr := ctx.Arg("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
failedTasks := make([]interface{}, 0)
|
||||
for _, rawTask := range tasks {
|
||||
task, ok := rawTask.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if succeed, ok := task["is_succeed"].(bool); ok && !succeed {
|
||||
failedTasks = append(failedTasks, map[string]interface{}{
|
||||
"uuid": task["uuid"],
|
||||
"event_type": task["event_type"],
|
||||
"delivered_time": task["delivered_time"],
|
||||
"status": "failed",
|
||||
})
|
||||
if limit > 0 && len(failedTasks) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"hooktasks": failedTasks,
|
||||
"total_count": len(failedTasks),
|
||||
})
|
||||
},
|
||||
},
|
||||
// +task-view:查看 Web 钩子投递任务的详细信息
|
||||
{
|
||||
Name: "task-view",
|
||||
Description: "查看 Web 钩子投递任务的详细信息",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "task-id", Usage: "Task ID (numeric)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, err := ctx.RequireArg("task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to parse hook tasks")
|
||||
}
|
||||
tasks, ok := raw["hooktasks"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no hook tasks found")
|
||||
}
|
||||
for _, rawTask := range tasks {
|
||||
task, ok := rawTask.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Support both numeric ID and UUID
|
||||
if tid, ok := task["id"].(float64); ok && fmt.Sprintf("%.0f", tid) == taskID {
|
||||
return ctx.OutputData(task)
|
||||
}
|
||||
if uuid, ok := task["uuid"].(string); ok && uuid == taskID {
|
||||
return ctx.OutputData(task)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("task %s not found", taskID)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runCreate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := webhookPayloadFromArgs(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", webhookPath(ctx), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := fetchWebhook(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch webhook: %w", err)
|
||||
}
|
||||
payload, err := webhookPayloadFromArgs(ctx, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func webhookPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func webhookItemPath(ctx *common.RuntimeContext, id string) string {
|
||||
return fmt.Sprintf("%s/%s", webhookPath(ctx), id)
|
||||
}
|
||||
|
||||
func fetchWebhook(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
|
||||
env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse webhook data")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func webhookPayloadFromArgs(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) {
|
||||
url := firstNonEmpty(ctx.Arg("url"), stringFromMap(current, "url"))
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("required flag --url is missing")
|
||||
}
|
||||
|
||||
eventValue := ctx.Arg("events")
|
||||
var events []string
|
||||
var err error
|
||||
if eventValue != "" {
|
||||
events, err = parseWebhookEvents(eventValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
events, err = eventsFromMap(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, fmt.Errorf("required flag --events is missing")
|
||||
}
|
||||
|
||||
webhookType := strings.ToLower(firstNonEmpty(ctx.Arg("type"), stringFromMap(current, "type"), "gitea"))
|
||||
if err := validateOneOf("type", webhookType, allowedWebhookTypes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentType := strings.ToLower(firstNonEmpty(ctx.Arg("content-type"), stringFromMap(current, "content_type"), "json"))
|
||||
if err := validateOneOf("content-type", contentType, allowedWebhookContentTypes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpMethod := strings.ToUpper(firstNonEmpty(ctx.Arg("http-method"), stringFromMap(current, "http_method"), "POST"))
|
||||
if err := validateOneOf("http-method", httpMethod, allowedWebhookMethods); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branchFilter := firstNonEmpty(ctx.Arg("branch-filter"), stringFromMap(current, "branch_filter"), "*")
|
||||
active, err := activeFromArgs(ctx.Arg("active"), current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": webhookType,
|
||||
"active": active,
|
||||
"content_type": contentType,
|
||||
"http_method": httpMethod,
|
||||
"url": url,
|
||||
"branch_filter": branchFilter,
|
||||
"events": events,
|
||||
}
|
||||
if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" {
|
||||
payload["secret"] = secret
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWebhookEvents(value string) ([]string, error) {
|
||||
parts := strings.Split(value, ",")
|
||||
events := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, part := range parts {
|
||||
event := strings.TrimSpace(part)
|
||||
if event == "" {
|
||||
continue
|
||||
}
|
||||
if !allowedWebhookEvents[event] {
|
||||
return nil, fmt.Errorf("invalid --events value %q", event)
|
||||
}
|
||||
if seen[event] {
|
||||
continue
|
||||
}
|
||||
seen[event] = true
|
||||
events = append(events, event)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, fmt.Errorf("required flag --events is missing")
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func eventsFromMap(values map[string]interface{}) ([]string, error) {
|
||||
if values == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, ok := values["events"]
|
||||
if !ok || raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch events := raw.(type) {
|
||||
case []interface{}:
|
||||
result := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
name, ok := event.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse webhook events")
|
||||
}
|
||||
result = append(result, name)
|
||||
}
|
||||
return result, nil
|
||||
case []string:
|
||||
return events, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to parse webhook events")
|
||||
}
|
||||
}
|
||||
|
||||
func activeFromArgs(value string, current map[string]interface{}) (bool, error) {
|
||||
if value != "" {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid --active value %q: use true or false", value)
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
if active, ok := current["active"].(bool); ok {
|
||||
return active, nil
|
||||
}
|
||||
if active, ok := current["is_active"].(bool); ok {
|
||||
return active, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func stringFromMap(values map[string]interface{}, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateOneOf(name, value string, allowed map[string]bool) error {
|
||||
if allowed[value] {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid --%s value %q", name, value)
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWebhookList(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks.json")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 1, "webhooks": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runWebhookShortcut(t, server, "list", nil); err != nil {
|
||||
t.Fatalf("list shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookView(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7.json")
|
||||
writeJSON(t, w, map[string]interface{}{"id": 7, "url": "https://example.com/hook"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runWebhookShortcut(t, server, "view", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCreatePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": 1})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runWebhookShortcut(t, server, "create", map[string]string{
|
||||
"url": "https://example.com/hook",
|
||||
"events": "push,issues_only,push",
|
||||
"type": "gitea",
|
||||
"content-type": "json",
|
||||
"http-method": "POST",
|
||||
"secret": "secret-token",
|
||||
"branch-filter": "master,{release*}",
|
||||
"active": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["url"], "https://example.com/hook")
|
||||
assertEqual(t, payload["type"], "gitea")
|
||||
assertEqual(t, payload["content_type"], "json")
|
||||
assertEqual(t, payload["http_method"], "POST")
|
||||
assertEqual(t, payload["secret"], "secret-token")
|
||||
assertEqual(t, payload["branch_filter"], "master,{release*}")
|
||||
assertEqual(t, payload["active"], true)
|
||||
assertStringSlice(t, payload["events"], []string{"push", "issues_only"})
|
||||
}
|
||||
|
||||
func TestWebhookUpdatePreservesCurrentFields(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": 7,
|
||||
"url": "https://old.example.com/hook",
|
||||
"type": "gitea",
|
||||
"content_type": "json",
|
||||
"http_method": "POST",
|
||||
"branch_filter": "*",
|
||||
"events": []string{"push"},
|
||||
"active": true,
|
||||
})
|
||||
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, payload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runWebhookShortcut(t, server, "update", map[string]string{
|
||||
"id": "7",
|
||||
"url": "https://new.example.com/hook",
|
||||
"events": "push,issue_comment",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["url"], "https://new.example.com/hook")
|
||||
assertEqual(t, payload["type"], "gitea")
|
||||
assertEqual(t, payload["content_type"], "json")
|
||||
assertEqual(t, payload["http_method"], "POST")
|
||||
assertEqual(t, payload["branch_filter"], "*")
|
||||
assertEqual(t, payload["active"], true)
|
||||
assertStringSlice(t, payload["events"], []string{"push", "issue_comment"})
|
||||
}
|
||||
|
||||
func TestWebhookDelete(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "DELETE", "/v1/owner/repo/webhooks/7.json")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runWebhookShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTestDelivery(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks/7/tests.json")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runWebhookShortcut(t, server, "test", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("test shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTasks(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7/hooktasks.json")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "hooktasks": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runWebhookShortcut(t, server, "tasks", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("tasks shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebhookEventsRejectsInvalidEvent(t *testing.T) {
|
||||
_, err := parseWebhookEvents("push,invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid event to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCreateRejectsInvalidActive(t *testing.T) {
|
||||
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid active should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runWebhookShortcut(t, server, "create", map[string]string{
|
||||
"url": "https://example.com/hook",
|
||||
"events": "push",
|
||||
"active": "maybe",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid active to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findWebhookShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findWebhookShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newWebhookTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSlice(t *testing.T, got interface{}, want []string) {
|
||||
t.Helper()
|
||||
values, ok := got.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want []interface{}", got)
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("got event %v (%T), want string", value, value)
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
if !reflect.DeepEqual(result, want) {
|
||||
t.Fatalf("got %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Overridable for testing ---
|
||||
|
||||
// wikiRepoURL returns the Git URL for the wiki repository.
|
||||
// Override wikiRepoURLOverride in tests to point to a local repo.
|
||||
var wikiRepoURLOverride string
|
||||
|
||||
func wikiRepoURL(owner, repo string) string {
|
||||
if wikiRepoURLOverride != "" {
|
||||
return wikiRepoURLOverride
|
||||
}
|
||||
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", owner, repo)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// encodePageName encodes a page name for use as a Git filename (URL-encoded + .md).
|
||||
func encodePageName(name string) string {
|
||||
return url.PathEscape(name) + ".md"
|
||||
}
|
||||
|
||||
// decodePageName decodes a wiki filename back to a display name.
|
||||
func decodePageName(filename string) string {
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
decoded, err := url.PathUnescape(name)
|
||||
if err != nil {
|
||||
return name
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// isWikiFile returns true if the filename is a regular wiki page (not _Sidebar, _Footer, etc.).
|
||||
func isWikiFile(name string) bool {
|
||||
return strings.HasSuffix(name, ".md") && !strings.HasPrefix(name, "_")
|
||||
}
|
||||
|
||||
// wikiFiles lists all wiki page files in a cloned repo directory, sorted.
|
||||
func wikiFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && isWikiFile(e.Name()) {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// runGit runs a git command in the specified directory.
|
||||
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
|
||||
// getAuthToken retrieves the auth token for Git push operations.
|
||||
func getAuthToken() (string, error) {
|
||||
if t := os.Getenv("GITLINK_TOKEN"); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
t, err := auth.LoadToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
|
||||
}
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// authWikiRepoURL returns an authenticated Git URL for push operations.
|
||||
func authWikiRepoURL(owner, repo, token string) (string, error) {
|
||||
if strings.HasPrefix(token, "cookie:") {
|
||||
return "", fmt.Errorf("cookie-based login does not support Git push. Generate a Personal Access Token on GitLink and use:\n gitlink-cli auth login --token")
|
||||
}
|
||||
return fmt.Sprintf("https://token:%s@gitlink.org.cn/%s/%s.wiki.git",
|
||||
url.QueryEscape(token), owner, repo), nil
|
||||
}
|
||||
|
||||
// cloneWikiReadOnly clones the wiki repo (read-only, no auth needed for public repos).
|
||||
func cloneWikiReadOnly(owner, repo, dest string) error {
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", wikiRepoURL(owner, repo), dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneWikiWithAuth clones the wiki repo with authentication for push.
|
||||
func cloneWikiWithAuth(authURL, dest string) error {
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", authURL, dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveContent reads content from --content or --file.
|
||||
func resolveContent(ctx *common.RuntimeContext) (string, error) {
|
||||
content := ctx.Arg("content")
|
||||
file := ctx.Arg("file")
|
||||
if content != "" && file != "" {
|
||||
return "", fmt.Errorf("use either --content or --file, not both")
|
||||
}
|
||||
if file != "" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
content = string(data)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// buildPageContent builds the markdown content, optionally prepending a title heading.
|
||||
func buildPageContent(title, content string) string {
|
||||
if title != "" {
|
||||
return "# " + title + "\n\n" + content
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// --- Shortcuts ---
|
||||
|
||||
// Shortcuts returns wiki management shortcuts backed by Git operations.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有 Wiki 文档页面
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有 Wiki 文档页面",
|
||||
Run: runList,
|
||||
},
|
||||
// +view:查看 Wiki 文档页面内容
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看 Wiki 文档页面内容",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
// +create:新建 Wiki 文档页面
|
||||
{
|
||||
Name: "create",
|
||||
Description: "新建 Wiki 文档页面",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
|
||||
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
|
||||
{Name: "file", Short: "f", Usage: "Read page content from file"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +update:更新 Wiki 文档页面
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新 Wiki 文档页面",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
|
||||
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
|
||||
{Name: "file", Short: "f", Usage: "Read page content from file"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: runUpdate,
|
||||
},
|
||||
// +delete:删除 Wiki 文档页面
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除 Wiki 文档页面",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
// +export:导出 Wiki 文档
|
||||
{
|
||||
Name: "export",
|
||||
Description: "导出 Wiki 文档",
|
||||
Flags: []common.Flag{
|
||||
{Name: "project-id", Usage: "Project ID (numeric)"},
|
||||
},
|
||||
Run: runExport,
|
||||
},
|
||||
// +import:从文件导入 Wiki 文档
|
||||
{
|
||||
Name: "import",
|
||||
Description: "从文件导入 Wiki 文档",
|
||||
Flags: []common.Flag{
|
||||
{Name: "project-id", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: runImport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command implementations ---
|
||||
|
||||
func runList(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := wikiFiles(tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pages []map[string]string
|
||||
for _, f := range files {
|
||||
pages = append(pages, map[string]string{
|
||||
"name": decodePageName(f),
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"total_count": len(pages),
|
||||
"pages": pages,
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(data, nil))
|
||||
}
|
||||
|
||||
func runView(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pageName, err := ctx.RequireArg("page-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := encodePageName(pageName)
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, fileName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("page %q not found", pageName)
|
||||
}
|
||||
|
||||
// Get last commit info for this file
|
||||
log, _, _ := runGit(tmpDir, "log", "-1", "--format=%H|%an|%ae|%aI", "--", fileName)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"name": pageName,
|
||||
"content": string(content),
|
||||
}
|
||||
if log != "" {
|
||||
parts := strings.SplitN(strings.TrimSpace(log), "|", 4)
|
||||
if len(parts) >= 1 {
|
||||
data["last_commit_sha"] = parts[0]
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
data["last_author"] = parts[1]
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
data["last_commit_date"] = parts[3]
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(data, nil))
|
||||
}
|
||||
|
||||
func runCreate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pageName, err := ctx.RequireArg("page-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if content == "" {
|
||||
return fmt.Errorf("either --content or --file is required")
|
||||
}
|
||||
|
||||
token, err := getAuthToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := encodePageName(pageName)
|
||||
filePath := filepath.Join(tmpDir, fileName)
|
||||
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
return fmt.Errorf("page %q already exists", pageName)
|
||||
}
|
||||
|
||||
pageContent := buildPageContent(ctx.Arg("title"), content)
|
||||
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
|
||||
return fmt.Errorf("write page: %w", err)
|
||||
}
|
||||
|
||||
msg := ctx.Arg("message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("Create wiki page %q", pageName)
|
||||
}
|
||||
|
||||
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
|
||||
return fmt.Errorf("git add: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
|
||||
return fmt.Errorf("git commit: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
|
||||
return fmt.Errorf("git push: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]string{
|
||||
"name": pageName,
|
||||
"message": "Wiki page created",
|
||||
})
|
||||
}
|
||||
|
||||
func runUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pageName, err := ctx.RequireArg("page-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := getAuthToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := encodePageName(pageName)
|
||||
filePath := filepath.Join(tmpDir, fileName)
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("page %q not found", pageName)
|
||||
}
|
||||
|
||||
content, err := resolveContent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
pageContent := buildPageContent(ctx.Arg("title"), content)
|
||||
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
|
||||
return fmt.Errorf("write page: %w", err)
|
||||
}
|
||||
} else if title := ctx.Arg("title"); title != "" {
|
||||
// Only updating title: read existing content and replace/re-add the heading
|
||||
existing, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read page: %w", err)
|
||||
}
|
||||
lines := strings.SplitN(string(existing), "\n", 2)
|
||||
newContent := "# " + title + "\n"
|
||||
if len(lines) > 1 {
|
||||
// Skip old heading if present
|
||||
body := lines[1]
|
||||
if strings.HasPrefix(lines[0], "# ") {
|
||||
newContent += body
|
||||
} else {
|
||||
newContent += strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("write page: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
msg := ctx.Arg("message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("Update wiki page %q", pageName)
|
||||
}
|
||||
|
||||
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
|
||||
return fmt.Errorf("git add: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
|
||||
return fmt.Errorf("git commit: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
|
||||
return fmt.Errorf("git push: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]string{
|
||||
"name": pageName,
|
||||
"message": "Wiki page updated",
|
||||
})
|
||||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pageName, err := ctx.RequireArg("page-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := getAuthToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := encodePageName(pageName)
|
||||
filePath := filepath.Join(tmpDir, fileName)
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("page %q not found", pageName)
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("delete page: %w", err)
|
||||
}
|
||||
// Remove _Sidebar.md so GitLink regenerates it from file list
|
||||
os.Remove(filepath.Join(tmpDir, "_Sidebar.md"))
|
||||
|
||||
msg := ctx.Arg("message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("Delete wiki page %q", pageName)
|
||||
}
|
||||
|
||||
if _, stderr, err := runGit(tmpDir, "add", "-A"); err != nil {
|
||||
return fmt.Errorf("git add: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
|
||||
return fmt.Errorf("git commit: %s%s", stderr, err)
|
||||
}
|
||||
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
|
||||
return fmt.Errorf("git push: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]string{
|
||||
"name": pageName,
|
||||
"message": "Wiki page deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func runExport(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
if v := ctx.Arg("project-id"); v != "" {
|
||||
q.Set("projectId", v)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/wikiExport/wikiExport-wrapper", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runImport(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := ctx.RequireArg("project-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/wikiExport/uploadWiki/%s/%s/%s", ctx.Owner, ctx.Repo, projectID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// setupWikiRepo creates a bare Git repository to simulate the remote wiki repo,
|
||||
// initializes it with sample wiki pages, and returns the path to the bare repo.
|
||||
// The caller must set wikiRepoURLOverride to this path before running shortcuts.
|
||||
func setupWikiRepo(t *testing.T) (bareRepo string, cleanup func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create a temp directory for the "remote" bare repo
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-remote-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir remote: %v", err)
|
||||
}
|
||||
|
||||
// Initialize bare repo
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
// Clone it to a working directory to add initial content
|
||||
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
|
||||
if err != nil {
|
||||
os.RemoveAll(remoteDir)
|
||||
t.Fatalf("mkdir work: %v", err)
|
||||
}
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
|
||||
// Configure git user for commits
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
|
||||
// Create initial wiki pages
|
||||
pages := map[string]string{
|
||||
"Home.md": "# Home\n\nWelcome to the wiki!",
|
||||
"Guide.md": "# Getting Started Guide\n\nThis is a guide.",
|
||||
"API-Reference.md": "# API Reference\n\nAPI documentation.",
|
||||
}
|
||||
for name, content := range pages {
|
||||
os.WriteFile(filepath.Join(workDir, name), []byte(content), 0644)
|
||||
}
|
||||
|
||||
// Commit and push initial content
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "Initial wiki pages")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
|
||||
// Clean up working directory
|
||||
os.RemoveAll(workDir)
|
||||
|
||||
return remoteDir, func() {
|
||||
os.RemoveAll(remoteDir)
|
||||
}
|
||||
}
|
||||
|
||||
// runGitClean runs a git command and panics on error (for test setup).
|
||||
func runGitClean(dir string, args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// runWikiShortcut runs a wiki shortcut with the given args using a local test repo.
|
||||
func runWikiShortcut(t *testing.T, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findWikiShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findWikiShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestWikiList(t *testing.T) {
|
||||
repo, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
wikiRepoURLOverride = repo
|
||||
defer func() { wikiRepoURLOverride = "" }()
|
||||
|
||||
if err := runWikiShortcut(t, "list", nil); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiView(t *testing.T) {
|
||||
repo, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
wikiRepoURLOverride = repo
|
||||
defer func() { wikiRepoURLOverride = "" }()
|
||||
|
||||
if err := runWikiShortcut(t, "view", map[string]string{
|
||||
"page-name": "Home",
|
||||
}); err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiViewNotFound(t *testing.T) {
|
||||
repo, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
wikiRepoURLOverride = repo
|
||||
defer func() { wikiRepoURLOverride = "" }()
|
||||
|
||||
err := runWikiShortcut(t, "view", map[string]string{
|
||||
"page-name": "NonExistent",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiViewMissingPageName(t *testing.T) {
|
||||
_, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
err := runWikiShortcut(t, "view", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --page-name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiListWithSpecialChars(t *testing.T) {
|
||||
repo, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
// Add a page with Chinese characters
|
||||
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir work: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
runGitClean("", "clone", repo, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
|
||||
pageName := "Wiki测试"
|
||||
fileName := encodePageName(pageName)
|
||||
os.WriteFile(filepath.Join(workDir, fileName), []byte("# Wiki测试\nHello"), 0644)
|
||||
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "Add Chinese page")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
|
||||
wikiRepoURLOverride = repo
|
||||
defer func() { wikiRepoURLOverride = "" }()
|
||||
|
||||
if err := runWikiShortcut(t, "list", nil); err != nil {
|
||||
t.Fatalf("list with special chars failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePageName(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
expected string
|
||||
}{
|
||||
{"Home", "Home.md"},
|
||||
{"Getting Started", "Getting%20Started.md"},
|
||||
{"API-Reference", "API-Reference.md"},
|
||||
{"Wiki测试", "Wiki%E6%B5%8B%E8%AF%95.md"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
encoded := encodePageName(tt.raw)
|
||||
if encoded != tt.expected {
|
||||
t.Errorf("encodePageName(%q) = %q, want %q", tt.raw, encoded, tt.expected)
|
||||
}
|
||||
decoded := decodePageName(encoded)
|
||||
if decoded != tt.raw {
|
||||
t.Errorf("decodePageName(%q) = %q, want %q", encoded, decoded, tt.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWikiFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{"Home.md", true},
|
||||
{"Guide.md", true},
|
||||
{"API-Reference.md", true},
|
||||
{"_Sidebar.md", false},
|
||||
{"_Footer.md", false},
|
||||
{"README.txt", false},
|
||||
{"image.png", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := isWikiFile(tt.name)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isWikiFile(%q) = %v, want %v", tt.name, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiListHandlesEmptyRepo(t *testing.T) {
|
||||
// Create a bare repo with no wiki pages
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-empty-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(remoteDir)
|
||||
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
// Clone and make an initial commit (so that clone works)
|
||||
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir work: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
|
||||
// Add a .gitkeep or similar non-wiki file to make the repo non-empty
|
||||
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "init")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
os.RemoveAll(workDir)
|
||||
|
||||
wikiRepoURLOverride = remoteDir
|
||||
defer func() { wikiRepoURLOverride = "" }()
|
||||
|
||||
if err := runWikiShortcut(t, "list", nil); err != nil {
|
||||
t.Fatalf("list empty repo failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiDeleteMissingPageName(t *testing.T) {
|
||||
_, cleanup := setupWikiRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
err := runWikiShortcut(t, "delete", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --page-name is missing")
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +108,14 @@ skills/
|
|||
│ └── ci-workflow.md # CI 工作流
|
||||
├── gitlink-pm/ # 项目管理
|
||||
│ └── SKILL.md # PM 操作指南
|
||||
├── gitlink-duplicate-detector/ # 重复 Issue 检测
|
||||
│ ├── SKILL.md # 重复检测与关联收敛指南
|
||||
│ └── examples/
|
||||
│ └── duplicate-detection-workflow.md # 端到端工作流与验证记录
|
||||
├── gitlink-pr-deep-review/ # PR 深度审查
|
||||
│ ├── SKILL.md # 编排 code-review + 设计/一致性推理
|
||||
│ └── examples/
|
||||
│ └── pr-deep-review-workflow.md # 端到端工作流与验证记录
|
||||
└── gitlink-workflow/ # AI 自动化工作流
|
||||
└── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
```
|
||||
|
|
@ -123,7 +131,7 @@ skills/
|
|||
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
|
||||
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
|
||||
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
|
||||
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
|
||||
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
|
||||
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
|
||||
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
|
||||
|
||||
|
|
@ -301,6 +309,8 @@ AI 代理可以:
|
|||
- ✅ 自动分类 Issue
|
||||
- ✅ 自动生成 Release Notes
|
||||
- ✅ 自动执行代码审查
|
||||
- ✅ 自动检测并收敛重复 Issue([gitlink-duplicate-detector](gitlink-duplicate-detector/SKILL.md))
|
||||
- ✅ PR 深度审查:实现质量 + 设计/需求一致性 + 跨模块影响([gitlink-pr-deep-review](gitlink-pr-deep-review/SKILL.md))
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
# gitlink-code-review API 参考
|
||||
|
||||
## PR 相关 API
|
||||
|
||||
### 获取 PR 详情
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --id <pull_request_id> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.issue.id` | int | Issue/PR 内部 ID |
|
||||
| `data.issue.subject` | string | PR 标题 |
|
||||
| `data.issue.description` | string | PR 描述 |
|
||||
| `data.issue.created_at` | string | 创建时间 |
|
||||
| `data.issue.issue_status` | string | 状态 |
|
||||
| `data.issue.author_login` | string | 作者登录名 |
|
||||
| `data.issue.author_name` | string | 作者名称 |
|
||||
| `data.pull_request.base` | string | 目标分支 |
|
||||
| `data.pull_request.head` | string | 源分支 |
|
||||
| `data.pull_request.status` | int | 0=open, 1=merged, 2=closed |
|
||||
| `data.pull_request.state` | string | "open" / "closed" |
|
||||
| `data.pull_request.mergeable` | boolean | 是否可合并 |
|
||||
| `data.pull_request.reviewers` | array | 审查者列表 |
|
||||
| `data.commits_count` | int | 提交数 |
|
||||
| `data.files_count` | int | 变更文件数 |
|
||||
| `data.conflict_files` | array | 冲突文件列表 |
|
||||
|
||||
### 获取 PR 变更文件列表
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --id <pull_request_id> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `files_count` | int | 文件总数 |
|
||||
| `total_addition` | int | 新增行数 |
|
||||
| `total_deletion` | int | 删除行数 |
|
||||
| `files[].name` | string | 文件名 |
|
||||
| `files[].addition` | int | 该文件新增行数 |
|
||||
| `files[].deletion` | int | 该文件删除行数 |
|
||||
| `files[].type` | int | 1=新增, 2=修改, 3=删除 |
|
||||
| `files[].isCreated` | boolean | 是否新建文件 |
|
||||
| `files[].isDeleted` | boolean | 是否删除文件 |
|
||||
| `files[].isRenamed` | boolean | 是否重命名 |
|
||||
|
||||
### 获取 PR Diff
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +diff --id <pull_request_id> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `files_count` | int | 文件总数 |
|
||||
| `total_addition` | int | 总新增行数 |
|
||||
| `total_deletion` | int | 总删除行数 |
|
||||
| `files[].sections[].lines[].leftIdx` | int | 原文件行号 |
|
||||
| `files[].sections[].lines[].rightIdx` | int | 新文件行号 |
|
||||
| `files[].sections[].lines[].type` | int | 1=未变, 2=新增, 3=删除, 4=统计信息 |
|
||||
| `files[].sections[].lines[].content` | string | 行内容 |
|
||||
|
||||
---
|
||||
|
||||
## PR 列表 API
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +list --state <open|merged|closed> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.open_count` | int | 开放 PR 数 |
|
||||
| `data.close_count` | int | 关闭 PR 数 |
|
||||
| `data.merged_issues_size` | int | 已合并 PR 数 |
|
||||
| `data.issues[].id` | int | PR 内部 ID |
|
||||
| `data.issues[].pull_request_number` | int | PR 编号(对外展示用) |
|
||||
| `data.issues[].name` | string | PR 标题 |
|
||||
| `data.issues[].author_login` | string | 作者登录名 |
|
||||
| `data.issues[].author_name` | string | 作者名称 |
|
||||
| `data.issues[].pull_request_status` | int | **关键字段:** 0=open, 1=merged, 2=closed |
|
||||
| `data.issues[].pull_request_base` | string | 目标分支 |
|
||||
| `data.issues[].pull_request_head` | string | 源分支 |
|
||||
| `data.issues[].pr_created_unix` | int | 创建时间戳 |
|
||||
| `data.issues[].pr_full_time` | string | 创建完整时间 |
|
||||
| `data.issues[].journals_count` | int | 评论数 |
|
||||
| `data.issues[].reviewers` | array | Reviewers 列表 |
|
||||
| `data.issues[].fork_project_id` | int | Fork 项目 ID |
|
||||
| `data.issues[].is_original` | boolean | 是否来自 Fork |
|
||||
|
||||
---
|
||||
|
||||
## Issue 相关 API
|
||||
|
||||
### 获取 Issue 列表
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.closed_count` | int | 已关闭 Issue 数 |
|
||||
| `data.issues[].id` | int | Issue ID |
|
||||
| `data.issues[].name` | string | Issue 标题 |
|
||||
| `data.issues[].author.login` | string | 作者登录名 |
|
||||
| `data.issues[].author.name` | string | 作者名称 |
|
||||
| `data.issues[].assigners` | array | 指派人列表 |
|
||||
| `data.issues[].priority.name` | string | 优先级名称 |
|
||||
| `data.issues[].priority.id` | int | 优先级 ID |
|
||||
| `data.issues[].created_at` | string | 创建时间 |
|
||||
| `data.issues[].milestone_name` | string | 里程碑名称 |
|
||||
| `data.issues[].due_date` | string | 截止日期 |
|
||||
|
||||
---
|
||||
|
||||
## 仓库 API
|
||||
|
||||
### 获取仓库信息
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.project_id` | int | 项目 ID |
|
||||
| `data.identifier` | string | 仓库标识 |
|
||||
| `data.name` | string | 项目名称 |
|
||||
| `data.full_name` | string | 全名(owner/repo) |
|
||||
| `data.default_branch` | string | 默认分支 |
|
||||
| `data.private` | boolean | 是否私有 |
|
||||
| `data.empty` | boolean | 是否空仓库 |
|
||||
| `data.issues_count` | int | Issue 总数 |
|
||||
| `data.pull_requests_count` | int | PR 总数 |
|
||||
| `data.forked_count` | int | Fork 数 |
|
||||
| `data.praises_count` | int | Star 数 |
|
||||
| `data.watchers_count` | int | 关注数 |
|
||||
| `data.contributor_users_count` | int | 贡献者数 |
|
||||
| `data.version_releases_count` | int | Release 数 |
|
||||
| `data.size` | string | 仓库大小 |
|
||||
| `data.clone_url` | string | HTTPS 克隆地址 |
|
||||
| `data.ssh_url` | string | SSH 克隆地址 |
|
||||
| `data.author.login` | string | 仓库所有者 |
|
||||
|
||||
### 获取仓库文件列表
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
|
||||
```
|
||||
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.entries[].name` | string | 文件/目录名 |
|
||||
| `data.entries[].type` | string | "dir" 或 "file" |
|
||||
| `data.entries[].path` | string | 相对路径 |
|
||||
| `data.entries[].size` | int | 文件大小(bytes) |
|
||||
| `data.entries[].sha` | string | 文件 SHA |
|
||||
| `data.entries[].content` | string | 文件内容(部分文件可能直接返回) |
|
||||
|
||||
### 获取仓库语言统计
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
```
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{ "Ruby": "90.2%", "JavaScript": "6.1%", "CSS": "3.7%" }
|
||||
```
|
||||
|
||||
### 获取仓库原始文件
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用户 API
|
||||
|
||||
### 查看当前用户
|
||||
|
||||
```bash
|
||||
gitlink-cli user +me --format json
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.login` | string | 登录名 |
|
||||
| `data.user_id` | int | 用户 ID |
|
||||
| `data.username` | string | 用户名 |
|
||||
| `data.email` | string | 邮箱 |
|
||||
| `data.phone` | string | 手机号 |
|
||||
| `data.admin` | boolean | 是否管理员 |
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /users/:user_id --format json
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.login` | string | 登录名 |
|
||||
| `data.name` | string | 显示名称 |
|
||||
| `data.user_id` | int | 用户 ID |
|
||||
| `data.user_identity` | string | 身份(专业人士/学生等) |
|
||||
| `data.user_projects_count` | int | 项目数 |
|
||||
| `data.common_projects_count` | int | 参与项目数 |
|
||||
| `data.created_time` | string | 注册时间 |
|
||||
| `data.gender` | int | 性别 |
|
||||
|
||||
---
|
||||
|
||||
## 数据获取最佳实践
|
||||
|
||||
1. **始终使用 `--format json`** 确保可解析输出
|
||||
2. PR ID 使用 `pull_request_id`(内部 ID),PR 编号展示用 `pull_request_number`
|
||||
3. PR 状态通过 `pull_request_status` 判断:0=open, 1=merged, 2=closed
|
||||
4. 大型 PR 的 diff 可能非常大,建议按文件分组逐文件分析
|
||||
5. Issue 的 `closed_count` 和 `open_count` 在列表的数据顶层
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
---
|
||||
name: gitlink-code-review
|
||||
version: 1.0.0
|
||||
description: "智能代码审查:获取 PR 变更、分析代码质量、自动生成 Review 评论与摘要报告。当用户需要审查 Pull Request、检查代码质量或生成审查报告时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pr --help"
|
||||
---
|
||||
|
||||
# gitlink-code-review(智能代码审查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 工作流概览
|
||||
|
||||
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。
|
||||
|
||||
| 阶段 | 操作 | AI Agent 角色 |
|
||||
|------|------|--------------|
|
||||
| ① 获取上下文 | 拉取 PR 详情、变更文件、Diff | 执行 CLI 命令采集数据 |
|
||||
| ② 分析代码 | 检查每个文件的变更 | 逐文件审查,标记问题 |
|
||||
| ③ 结构化反馈 | 按严重程度分级输出审查意见 | 生成分级 Review 评论 |
|
||||
| ④ 提交评论 | 发表 Review 到 PR | 通过 API 提交 |
|
||||
| ⑤ 生成报告 | 输出审查摘要 | 生成 Markdown 摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 详细工作流
|
||||
|
||||
### 工作流 1:PR 代码审查
|
||||
|
||||
**场景**:收到 PR Review 请求后,进行完整代码审查。
|
||||
|
||||
#### Step 1:获取 PR 上下文
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
|
||||
# 获取变更文件列表
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
|
||||
# 获取 Diff 内容(含变更行号和代码上下文)
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
```
|
||||
|
||||
#### Step 2:逐文件分析
|
||||
|
||||
对每个变更文件,根据文件类型执行针对性检查:
|
||||
|
||||
**Python 文件检查项:**
|
||||
- 语法与导入:未使用的 import、循环导入、wildcard import
|
||||
- 代码规范:PEP 8 风格偏离、过长行(>88 chars)、命名规范
|
||||
- 安全:硬编码密钥、SQL 注入风险、`eval()`/`exec()` 使用
|
||||
- 性能:不必要的循环、缺少缓存、N+1 查询
|
||||
- 错误处理:裸 `except`、吞异常、缺少 finally
|
||||
|
||||
**JavaScript/TypeScript 文件检查项:**
|
||||
- 安全:`innerHTML` 直接赋值、`eval()` 使用
|
||||
- 类型安全:`any` 滥用、缺失类型定义
|
||||
- 性能:不必要的 re-render、大对象深拷贝
|
||||
- 异步:未处理的 Promise、缺少 error boundary
|
||||
- 依赖:已废弃 API 使用
|
||||
|
||||
**Go 文件检查项:**
|
||||
- 错误处理:未检查的 error return、panic 滥用
|
||||
- 并发:goroutine 泄漏、缺少 sync 保护
|
||||
- 资源管理:未关闭的 file/conn、defer 使用
|
||||
- 命名:导出标识符缺少注释、变量 shadowing
|
||||
|
||||
**通用检查项:**
|
||||
- 硬编码的配置值、密钥、URL
|
||||
- 缺少或错误的边界条件检查
|
||||
- 过于复杂的函数(圈复杂度高)
|
||||
- 魔法数字(未命名的常量)
|
||||
- 重复代码(DRY 违反)
|
||||
- 缺少或过时的注释
|
||||
- 测试覆盖不足
|
||||
|
||||
#### Step 3:生成结构化审查结果
|
||||
|
||||
按以下 Severity 分级输出:
|
||||
|
||||
```markdown
|
||||
## PR #<id> 代码审查报告
|
||||
|
||||
### 🔴 Critical(必须修改)
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### 🟡 Warning(建议修改)
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### 🔵 Suggestion(可选优化)
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### ✅ Positive(值得肯定)
|
||||
- <做得好的地方>
|
||||
```
|
||||
|
||||
#### Step 4:提交 Review 评论
|
||||
|
||||
```bash
|
||||
# 方式 1:提交整体 Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价:...",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
|
||||
# 方式 2:在特定行添加内联评论(逐条提交)
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。",
|
||||
"event": "COMMENT",
|
||||
"commit_id": "<commit_sha>",
|
||||
"path": "src/query.py",
|
||||
"position": 42
|
||||
}'
|
||||
```
|
||||
|
||||
> **注意:** `event` 参数支持 `COMMENT`(普通评论)和 `APPROVE`(批准)。对于需要修改的问题,使用 `COMMENT`。
|
||||
|
||||
#### Step 5:生成审查摘要
|
||||
|
||||
审查完成后,输出 Markdown 摘要供用户查阅:
|
||||
|
||||
```markdown
|
||||
## 📋 审查摘要 — PR #<id> <title>
|
||||
|
||||
| 指标 | 数据 |
|
||||
|------|------|
|
||||
| 审查文件数 | <n> |
|
||||
| 变更行数 | +<add> / -<del> |
|
||||
| Critical 问题 | <n> |
|
||||
| Warning | <n> |
|
||||
| Suggestion | <n> |
|
||||
|
||||
### 主要发现
|
||||
1. **[Critical]** <最严重的问题>
|
||||
2. **[Warning]** <次要问题>
|
||||
3. **[Suggestion]** <优化建议>
|
||||
|
||||
### 总体评价
|
||||
<整体评估:代码质量、审查通过建议>
|
||||
|
||||
---
|
||||
*由 gitlink-code-review Skill 自动生成*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 工作流 2:仓库代码健康度扫描
|
||||
|
||||
**场景**:对仓库整体代码质量进行评估,不依赖 PR。
|
||||
|
||||
```bash
|
||||
# 1. 获取仓库信息
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 2. 获取仓库文件列表(遍历关键目录)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=tests&ref=master'
|
||||
|
||||
# 3. 获取关键文件内容
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/README.md
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.gitignore
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.eslintrc.js # 或类似配置
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/package.json # 或 go.mod, Cargo.toml
|
||||
|
||||
# 4. 获取语言统计和贡献者
|
||||
gitlink-cli api GET /:owner/:repo/languages
|
||||
gitlink-cli api GET /:owner/:repo/contributors
|
||||
```
|
||||
|
||||
**健康度检查清单:**
|
||||
|
||||
| 检查项 | 标准 | 评分依据 |
|
||||
|--------|------|----------|
|
||||
| 文档完整性 | 有 README、CONTRIBUTING、CHANGELOG | 文件是否存在、内容质量 |
|
||||
| 许可证 | 有 LICENSE 文件 | 是否存在、是否合规 |
|
||||
| CI 配置 | 有 CI 配置(.github/workflows, Jenkinsfile 等) | 文件是否存在 |
|
||||
| 代码规范 | 有 linter 配置 | eslint/prettier/ruff/pylint 等 |
|
||||
| 测试覆盖 | 有 test 目录或测试文件 | 测试文件比例 |
|
||||
| 依赖管理 | 依赖文件完整且无已知漏洞 | package-lock/go.sum/poetry.lock |
|
||||
| Issue 健康度 | Issue 有分类标签、响应及时 | 通过 Issue 列表分析 |
|
||||
|
||||
**输出格式:**
|
||||
|
||||
```markdown
|
||||
## 🏥 仓库健康度报告 — <owner>/<repo>
|
||||
|
||||
### 总体评分:<⭐x/5>
|
||||
|
||||
| 维度 | 状态 | 评分 | 建议 |
|
||||
|------|:----:|:----:|------|
|
||||
| 📖 文档 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🔧 CI/CD | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🎨 代码规范 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🧪 测试覆盖 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 📦 依赖安全 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🐛 Issue 管理 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
|
||||
### 关键发现
|
||||
1. <最需要改进的问题>
|
||||
2. <次要问题>
|
||||
3. <做得好的方面>
|
||||
|
||||
### 改进路线图
|
||||
- **紧急(本周):** ...
|
||||
- **短期(本月):** ...
|
||||
- **长期(本季度):** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 工作流 3:批量 Issue Triage + 自动分配
|
||||
|
||||
**场景**:对新 Issue 进行自动分类、标签分配和责任人推荐。
|
||||
|
||||
```bash
|
||||
# 1. 获取未标记的 Issue
|
||||
gitlink-cli issue +list --state open --format json
|
||||
|
||||
# 2. 逐个分析 Issue 内容
|
||||
gitlink-cli issue +view --id <issue_id> --format json
|
||||
|
||||
# 3. 根据内容智能分类
|
||||
# 分析标题和描述后,通过 Raw API 打标签
|
||||
gitlink-cli api POST /:owner/:repo/issues/:id --body '{
|
||||
"issue_tag_ids": [<tag_id>],
|
||||
"done_ratio": 0,
|
||||
"subject": "<原始标题>",
|
||||
"description": "<原始描述>"
|
||||
}'
|
||||
```
|
||||
|
||||
**分类规则参考:**
|
||||
|
||||
| Issue 关键词 | 推荐标签 | 优先级 |
|
||||
|-------------|----------|:------:|
|
||||
| bug, 错误, 失败, crash, 崩溃 | bug | 🔴 High |
|
||||
| feature, 新增, 建议, 希望 | enhancement | 🔵 Low |
|
||||
| 安全, 漏洞, 权限, 泄露 | security | 🔴 High |
|
||||
| 性能, 慢, 卡顿, 优化 | performance | 🟡 Medium |
|
||||
| 文档, README, 注释 | documentation | 🔵 Low |
|
||||
| question, 如何, 怎么, 请问 | question | 🟡 Medium |
|
||||
| 测试, test, 覆盖率 | testing | 🔵 Low |
|
||||
|
||||
---
|
||||
|
||||
## Raw API 参考
|
||||
|
||||
代码审查相关的 GitLink API 端点:
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id --format json
|
||||
|
||||
# 获取 PR 变更文件列表
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json
|
||||
|
||||
# 获取 PR Diff
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json
|
||||
|
||||
# 提交 PR Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
|
||||
|
||||
# 获取仓库文件列表
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
|
||||
|
||||
# 获取仓库语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
|
||||
# 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
|
||||
# 获取仓库动态
|
||||
gitlink-cli api GET /:owner/:repo/activity --format json
|
||||
```
|
||||
|
||||
## 代码审查最佳实践
|
||||
|
||||
### 审查原则
|
||||
|
||||
1. **先大局后细节**:先理解 PR 的目的和整体变更范围,再逐文件审查
|
||||
2. **关注行为,而非风格**:自动化工具(linter/formatter)能处理的风格问题优先交给工具
|
||||
3. **提供可操作的建议**:不只是指出问题,要给出具体的修改方案
|
||||
4. **肯定好的代码**:发现好的设计、清晰的命名、完善的测试时给予正面反馈
|
||||
5. **控制评论量**:避免信息过载——最严重的 3-5 个问题比 20 个小问题更有价值
|
||||
|
||||
### 安全红线
|
||||
|
||||
以下问题必须标记为 **Critical**,不得忽略:
|
||||
|
||||
- 硬编码的密钥 / Token / 密码
|
||||
- SQL / NoSQL 注入漏洞
|
||||
- 命令注入(shell 命令拼接)
|
||||
- 路径遍历(用户输入直接用于文件路径)
|
||||
- 不安全的反序列化
|
||||
- XSS(未转义的用户输入直接渲染)
|
||||
|
||||
### 输出规范
|
||||
|
||||
- 始终使用 `--format json` 获取结构化数据
|
||||
- 审查报告输出为 **Markdown 格式**,便于直接粘贴到 PR 评论
|
||||
- 涉及文件/行号时使用精准引用,方便定位
|
||||
- 批量操作前使用 `--dry-run` 预检
|
||||
|
||||
## 注意事项
|
||||
|
||||
- PR Review 提交后会通知所有关注该 PR 的参与者,评论内容请保持专业
|
||||
- `pr +diff` 输出可能很大(大型 PR),Agent 应分段处理
|
||||
- API 的 PR files 和 diff 接口有频率限制,避免短时间内重复请求
|
||||
- 对于 draft PR(草稿),应提示用户先将其标记为 Ready for Review
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# PR 代码审查完整工作流示例
|
||||
|
||||
**场景**:团队成员提交了一个 PR,需要进行代码审查。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `gitlink-cli` 已安装并登录
|
||||
- 用户拥有 PR 所在仓库的读取权限
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### Step 1:获取 PR 上下文
|
||||
|
||||
```bash
|
||||
# 查看 PR 列表,找到待审查的 PR
|
||||
gitlink-cli pr +list --state open --format json
|
||||
|
||||
# 获取特定 PR 详情
|
||||
gitlink-cli pr +view --id 42 --format json
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 42,
|
||||
"title": "feat: add user authentication module",
|
||||
"body": "实现了基于 JWT 的用户认证模块,包含登录、注册、Token 刷新功能。",
|
||||
"state": "open",
|
||||
"author": "developer_a",
|
||||
"created_at": "2026-05-18T10:30:00+08:00",
|
||||
"source_branch": "feat/auth-module",
|
||||
"target_branch": "master"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2:获取变更文件
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --id 42 --format json
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{ "filename": "src/auth/login.py", "status": "added", "additions": 120, "deletions": 0 },
|
||||
{ "filename": "src/auth/token.py", "status": "added", "additions": 85, "deletions": 0 },
|
||||
{ "filename": "src/config.py", "status": "modified", "additions": 5, "deletions": 2 },
|
||||
{ "filename": "tests/test_auth.py", "status": "added", "additions": 200, "deletions": 0 },
|
||||
{ "filename": "requirements.txt", "status": "modified", "additions": 3, "deletions": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3:获取 Diff 内容
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +diff --id 42 --format json
|
||||
```
|
||||
|
||||
### Step 4:逐文件审查
|
||||
|
||||
对每个变更文件,分析代码质量。以下是审查结果示例:
|
||||
|
||||
```markdown
|
||||
## PR #42 代码审查报告
|
||||
|
||||
### 🔴 Critical
|
||||
|
||||
1. **JWT Secret 硬编码** — `src/config.py:15`
|
||||
> `JWT_SECRET = "my-secret-key-123"` 硬编码在源码中,存在泄露风险。建议:
|
||||
> - 使用环境变量:`JWT_SECRET = os.getenv("JWT_SECRET")`
|
||||
> - 或使用配置文件(不提交到版本控制)
|
||||
|
||||
2. **SQL 注入风险** — `src/auth/login.py:42`
|
||||
> `cursor.execute(f"SELECT * FROM users WHERE username = '{username}'")` 直接拼接用户输入,存在 SQL 注入风险。建议使用参数化查询:
|
||||
> ```python
|
||||
> cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||
> ```
|
||||
|
||||
### 🟡 Warning
|
||||
|
||||
1. **密码明文存储** — `src/auth/login.py:88`
|
||||
> 密码直接存储到数据库,建议使用 `bcrypt` 或 `argon2` 进行哈希处理。
|
||||
|
||||
2. **缺少输入验证** — `src/auth/login.py:15`
|
||||
> `login()` 函数没有对 `username` 和 `password` 进行长度和格式校验。建议:
|
||||
> ```python
|
||||
> if len(username) < 3 or len(username) > 50:
|
||||
> raise ValueError("用户名长度应在 3-50 个字符之间")
|
||||
> ```
|
||||
|
||||
### 🔵 Suggestion
|
||||
|
||||
1. **Token 过期时间可配置** — `src/auth/token.py:30`
|
||||
> `ACCESS_TOKEN_EXPIRE_MINUTES = 30` 建议改为从环境变量读取,方便不同环境配置。
|
||||
|
||||
2. **测试可增加边界用例** — `tests/test_auth.py`
|
||||
> 现有测试覆盖了正常流程,建议补充:
|
||||
> - 空用户名/密码
|
||||
> - 超长输入
|
||||
> - Token 过期处理
|
||||
> - 并发登录场景
|
||||
|
||||
### ✅ Positive
|
||||
|
||||
- 完整的测试覆盖(200 行测试代码,覆盖主要功能路径)
|
||||
- 清晰的模块划分(login / token 职责分离)
|
||||
- 有类型注解,代码可读性好
|
||||
```
|
||||
|
||||
### Step 5:提交 Review
|
||||
|
||||
```bash
|
||||
# 提交整体 Review 评论
|
||||
gitlink-cli api POST /Gitlink/forgeplus/pulls/42/reviews --body '{
|
||||
"body": "## PR #42 代码审查报告\n\n### 🔴 Critical\n\n1. **JWT Secret 硬编码** — `src/config.py:15`\n JWT_SECRET 硬编码在源码中。建议使用 `os.getenv(\"JWT_SECRET\")`。\n\n2. **SQL 注入风险** — `src/auth/login.py:42`\n 直接拼接用户输入到 SQL 查询。建议使用参数化查询。\n\n### 🟡 Warning\n\n1. **密码明文存储** — 建议使用 bcrypt 哈希处理。\n\n### 总体评价\n\n代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
```
|
||||
|
||||
### Step 6:输出审查摘要
|
||||
|
||||
```markdown
|
||||
## 📋 审查摘要 — PR #42 feat: add user authentication module
|
||||
|
||||
| 指标 | 数据 |
|
||||
|------|------|
|
||||
| 审查文件数 | 5 |
|
||||
| 变更行数 | +413 / -2 |
|
||||
| Critical 问题 | 2 |
|
||||
| Warning | 2 |
|
||||
| Suggestion | 2 |
|
||||
|
||||
### 主要发现
|
||||
1. **[Critical]** JWT Secret 硬编码在源码中
|
||||
2. **[Critical]** SQL 查询存在注入风险
|
||||
3. **[Warning]** 密码明文存储
|
||||
|
||||
### 总体评价
|
||||
代码结构良好,测试覆盖完整。修复两个安全关键问题后即可合并。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整命令速览
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli pr +view --id <id> --format json
|
||||
|
||||
# 获取变更文件
|
||||
gitlink-cli pr +files --id <id> --format json
|
||||
|
||||
# 获取 Diff
|
||||
gitlink-cli pr +diff --id <id> --format json
|
||||
|
||||
# 提交 Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
|
||||
```
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
---
|
||||
name: gitlink-commit-quality
|
||||
version: 1.0.0
|
||||
description: "提交质量守护:检查提交信息规范(Conventional Commits)、PR 描述完整性、分支命名规范、变更文件合理性审核。当用户需要规范团队提交流程、审查代码提交质量时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pr --help"
|
||||
---
|
||||
|
||||
# gitlink-commit-quality(提交质量守护)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 功能概述
|
||||
|
||||
本技能覆盖开发流程中的提交质量管控,包括:
|
||||
|
||||
1. **Commit Message 规范检查** — 验证是否符合 Conventional Commits 规范
|
||||
2. **PR 质量检查** — PR 标题、描述、关联 Issue 完整性
|
||||
3. **分支命名规范** — 检查分支名是否符合团队约定
|
||||
4. **变更合理性检查** — 一次提交是否改动过多、是否有无关文件混入
|
||||
|
||||
---
|
||||
|
||||
## 一、Commit Message 规范检查
|
||||
|
||||
### Conventional Commits 规范
|
||||
|
||||
标准格式:
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
**合法的 type 值:**
|
||||
|
||||
| type | 含义 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新增功能 | `feat(auth): 添加 JWT 认证` |
|
||||
| `fix` | 修复 Bug | `fix(login): 修复密码验证逻辑错误` |
|
||||
| `docs` | 文档变更 | `docs: 更新 API 接口文档` |
|
||||
| `style` | 代码格式(不影响逻辑) | `style: 统一缩进为 4 空格` |
|
||||
| `refactor` | 代码重构 | `refactor(user): 提取公共验证逻辑` |
|
||||
| `perf` | 性能优化 | `perf(query): 优化数据库查询索引` |
|
||||
| `test` | 测试相关 | `test(auth): 补充登录测试用例` |
|
||||
| `build` | 构建系统变更 | `build: 升级 Go 版本到 1.21` |
|
||||
| `ci` | CI 配置变更 | `ci: 添加 lint 检查步骤` |
|
||||
| `chore` | 其他杂项 | `chore: 更新 .gitignore` |
|
||||
| `revert` | 回滚提交 | `revert: revert feat(auth): ...` |
|
||||
|
||||
### 检查 PR 的所有提交信息
|
||||
|
||||
```bash
|
||||
# 获取 PR 关联的提交列表
|
||||
gitlink-cli pr +diff --id <pr_id> --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 或通过 Raw API 获取提交详情
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:pr_id/commits --format json
|
||||
```
|
||||
|
||||
### Commit Message 质量检查清单
|
||||
|
||||
对每条提交信息执行以下检查:
|
||||
|
||||
```
|
||||
✅ 必须检查:
|
||||
□ type 是否是合法值(feat/fix/docs/style/refactor/perf/test/build/ci/chore/revert)
|
||||
□ description 是否是中文或英文(不能是无意义内容如 "update"、"fix"、"tmp"、"xxx")
|
||||
□ description 首字母是否小写(英文时)
|
||||
□ description 末尾是否没有句号
|
||||
□ 整条信息是否超过 72 个字符(首行)
|
||||
|
||||
⚠️ 建议检查:
|
||||
□ scope 是否有意义(如 auth/user/api/db 等模块名)
|
||||
□ Breaking Change 是否在 footer 中标注(BREAKING CHANGE: ...)
|
||||
□ 关联 Issue 是否在 footer 中标注(Closes #123 / Fixes #456)
|
||||
```
|
||||
|
||||
### 不合规示例 vs 合规示例
|
||||
|
||||
| 不合规 | 问题 | 合规写法 |
|
||||
|--------|------|---------|
|
||||
| `update` | 无意义描述 | `feat(user): 新增用户头像上传功能` |
|
||||
| `fix bug` | 无具体说明 | `fix(login): 修复手机号登录时验证码未清除的问题` |
|
||||
| `WIP` | 临时提交混入 | 应在本地整理后再提交 |
|
||||
| `Feat: Add search` | type 大写 | `feat(search): 添加全文搜索功能` |
|
||||
| `feat: 添加搜索.` | 末尾有句号 | `feat(search): 添加搜索功能` |
|
||||
|
||||
---
|
||||
|
||||
## 二、PR 质量检查
|
||||
|
||||
### 获取 PR 信息
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### PR 质量检查清单
|
||||
|
||||
```
|
||||
✅ 必须检查:
|
||||
□ PR 标题是否符合 Conventional Commits 格式
|
||||
□ PR 描述是否有实质内容(不能为空或仅有一行)
|
||||
□ PR 描述是否说明了"做了什么"和"为什么这么做"
|
||||
□ PR 变更文件数量是否合理(建议单次 PR < 20 文件)
|
||||
□ PR 变更行数是否合理(建议单次 PR < 500 行净变更)
|
||||
|
||||
⚠️ 建议检查:
|
||||
□ PR 描述是否关联了对应 Issue(格式:Closes #123)
|
||||
□ 是否有 Test Plan(如何验证这次改动)
|
||||
□ 是否有截图(UI 改动)
|
||||
□ 是否更新了相关文档
|
||||
```
|
||||
|
||||
### PR 描述模板(推荐)
|
||||
|
||||
在 Review 评论中可以建议作者按以下模板完善 PR 描述:
|
||||
|
||||
```markdown
|
||||
## 变更说明
|
||||
|
||||
<!-- 简述本次 PR 做了什么 -->
|
||||
|
||||
## 原因/背景
|
||||
|
||||
<!-- 为什么需要这个改动?关联的 Issue? -->
|
||||
Closes #<issue_number>
|
||||
|
||||
## 测试方案
|
||||
|
||||
<!-- 如何验证这次改动是正确的? -->
|
||||
- [ ] 单元测试通过
|
||||
- [ ] 手动测试步骤:...
|
||||
|
||||
## 截图(如有 UI 变更)
|
||||
|
||||
<!-- 添加 before/after 截图 -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] 代码符合项目编码规范
|
||||
- [ ] 相关文档已更新
|
||||
- [ ] 测试覆盖新增/修改的代码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、分支命名规范检查
|
||||
|
||||
### 获取分支信息
|
||||
|
||||
```bash
|
||||
# 从 PR 信息中获取分支名
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
# 关注 head_branch 字段
|
||||
|
||||
# 列出所有分支
|
||||
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 推荐的分支命名规范
|
||||
|
||||
```
|
||||
格式:<type>/<short-description>
|
||||
或: <type>/<issue-id>-<short-description>
|
||||
|
||||
示例:
|
||||
feat/user-authentication
|
||||
fix/login-password-validation
|
||||
fix/123-token-expiry-bug
|
||||
docs/api-reference-update
|
||||
refactor/extract-auth-middleware
|
||||
release/v1.2.0
|
||||
hotfix/critical-security-patch
|
||||
```
|
||||
|
||||
### 不合规的分支名示例
|
||||
|
||||
| 不合规 | 问题 | 建议 |
|
||||
|--------|------|------|
|
||||
| `test123` | 无意义 | `test/add-login-unit-tests` |
|
||||
| `dev` / `development` | 过于宽泛 | 使用具体功能描述 |
|
||||
| `zhangsan_feature` | 用人名命名 | 用功能命名 |
|
||||
| `fix-bug` | 不够具体 | `fix/login-crash-on-empty-password` |
|
||||
| 含中文 | 可能导致编码问题 | 使用英文 |
|
||||
|
||||
---
|
||||
|
||||
## 四、变更合理性检查
|
||||
|
||||
### 获取变更文件
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --id <pr_id> --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 变更文件审核要点
|
||||
|
||||
```
|
||||
📊 规模检查:
|
||||
□ 变更文件数 > 20:建议拆分成多个 PR
|
||||
□ 净变更行数 > 500:建议拆分
|
||||
□ 单个文件变更 > 300 行:值得重点关注
|
||||
|
||||
🔍 文件类型检查:
|
||||
□ 是否有无关文件混入(如调试文件、个人 IDE 配置)
|
||||
□ 是否有 .env 文件、密钥文件被提交
|
||||
□ 是否有二进制文件或大文件(图片、视频等)
|
||||
□ 是否有自动生成文件(lock 文件、build 产物)混入功能 PR
|
||||
|
||||
⚠️ 敏感文件检查(高优先级):
|
||||
□ .env / .env.local / .env.production
|
||||
□ *.pem / *.key / *.p12 / *.pfx
|
||||
□ id_rsa / id_dsa
|
||||
□ config/secret* / credentials*
|
||||
□ *password* / *secret* / *token*(文件名)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、完整质量报告输出格式
|
||||
|
||||
```markdown
|
||||
## 📋 提交质量检查报告
|
||||
|
||||
**检查范围:** PR #42 — feat: 添加用户认证模块
|
||||
|
||||
---
|
||||
|
||||
### 1. PR 基本信息
|
||||
|
||||
| 项目 | 检查结果 |
|
||||
|------|---------|
|
||||
| PR 标题格式 | ✅ 符合规范 |
|
||||
| PR 描述完整性 | ⚠️ 缺少 Test Plan |
|
||||
| 关联 Issue | ✅ Closes #88 |
|
||||
| 变更规模 | ✅ 12 文件 / +248 -89 行 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 提交信息检查
|
||||
|
||||
| 提交 SHA | 提交信息 | 检查结果 |
|
||||
|---------|---------|---------|
|
||||
| `a1b2c3d` | feat(auth): 添加 JWT 生成 | ✅ 合规 |
|
||||
| `e4f5g6h` | fix login bug | ❌ 不合规 |
|
||||
| `i7j8k9l` | WIP | ❌ 不合规 |
|
||||
|
||||
**问题详情:**
|
||||
|
||||
**[1] 提交 e4f5g6h: "fix login bug"**
|
||||
- 问题:缺少规范 type 格式,描述过于模糊
|
||||
- 建议:`fix(login): 修复登录时密码验证逻辑错误`
|
||||
|
||||
**[2] 提交 i7j8k9l: "WIP"**
|
||||
- 问题:临时 WIP 提交不应出现在正式 PR 中
|
||||
- 建议:`git rebase -i HEAD~2` 将相关提交合并,并写一个规范的提交信息
|
||||
|
||||
---
|
||||
|
||||
### 3. 分支命名
|
||||
|
||||
| 分支名 | 检查结果 |
|
||||
|-------|---------|
|
||||
| `feature/user-auth` | ✅ 符合规范 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 变更文件审核
|
||||
|
||||
✅ 规模合理(12 文件,248 行净增加)
|
||||
|
||||
⚠️ **发现 1 个潜在问题:**
|
||||
- `config/app.env`:`.env` 文件被提交,请确认是否包含敏感配置
|
||||
|
||||
---
|
||||
|
||||
### 5. 改进建议
|
||||
|
||||
1. 完善 PR 描述,添加 Test Plan(必须)
|
||||
2. 修复 2 条不规范的提交信息(建议通过 rebase 整理)
|
||||
3. 确认 `.env` 文件是否应该提交,建议加入 `.gitignore`
|
||||
|
||||
---
|
||||
|
||||
**总体评级:** ⚠️ 需要改进(2个必须修复,1个建议)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、配合 CI 的自动化质量门禁
|
||||
|
||||
可将以下检查逻辑集成到 CI 流程,作为 PR 合并前的质量门禁:
|
||||
|
||||
```bash
|
||||
# 在 CI 中检查最新提交的 commit message
|
||||
git log --oneline -10
|
||||
|
||||
# 统计 PR 变更规模
|
||||
gitlink-cli pr +files --id $PR_ID --format json | \
|
||||
jq '{files: [.data[].filename] | length, additions: [.data[].additions] | add, deletions: [.data[].deletions] | add}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ✅ **提交规范的推行应循序渐进**,先建议后强制
|
||||
- ✅ **WIP 提交**:允许在开发分支中存在,但合并到主干前需 squash/rebase
|
||||
- ⚠️ **历史遗留**:老代码的提交不在检查范围,只检查本次 PR 新增的提交
|
||||
- ✅ **团队约定优先**:如团队有自己的规范,以团队规范为准,本技能提供通用参考
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# gitlink-compliance API 参考
|
||||
|
||||
## 文件检查 API
|
||||
|
||||
合规检查的核心是读取仓库中的文件内容,判断是否存在、内容是否正确。
|
||||
|
||||
### 读取文件内容
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
|
||||
```
|
||||
|
||||
**说明:** 直接返回文件原始内容,用于检查 LICENSE、README、CONTRIBUTING 等文件。
|
||||
|
||||
### 获取文件列表
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.entries[].name` | string | 文件名 |
|
||||
| `data.entries[].type` | string | "file" 或 "dir" |
|
||||
| `data.entries[].size` | int | 文件大小(字节) |
|
||||
| `data.entries[].sha` | string | 文件 SHA |
|
||||
| `data.entries[].commit.message` | string | 最后提交信息 |
|
||||
| `data.entries[].commit.created_at` | string | 最后修改时间 |
|
||||
| `data.entries[].is_readme_file` | boolean | 是否为 README 文件 |
|
||||
| `data.entries[].direct_download` | boolean | 是否可直接下载 |
|
||||
|
||||
### 获取仓库信息
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 用途 |
|
||||
|------|------|------|
|
||||
| `data.private` | boolean | 判断仓库可见性 |
|
||||
| `data.default_branch` | string | 默认分支 |
|
||||
| `data.license` | string | 许可证(如平台有返回) |
|
||||
| `data.author.login` | string | 所有者 |
|
||||
|
||||
### 获取贡献者列表
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
```
|
||||
|
||||
用于检查贡献者是否签署了 CLA/DCO。
|
||||
|
||||
---
|
||||
|
||||
## 常见许可证识别
|
||||
|
||||
通过读取 LICENSE 文件内容,关键字匹配识别许可证类型:
|
||||
|
||||
| 许可证 | 关键字 | 兼容性 |
|
||||
|--------|--------|--------|
|
||||
| MIT | "MIT License", "Permission is hereby granted" | Apache-2.0, BSD, ISC |
|
||||
| Apache-2.0 | "Apache License", "Version 2.0" | MIT, BSD, ISC |
|
||||
| GPL-2.0 | "GNU GENERAL PUBLIC LICENSE", "Version 2" | 严格 copyleft |
|
||||
| GPL-3.0 | "GNU GENERAL PUBLIC LICENSE", "Version 3" | 严格 copyleft |
|
||||
| BSD-2/3 | "BSD", "Redistribution and use" | MIT, Apache-2.0 |
|
||||
| MulanPSL-2 | "木兰", "Mulan Permissive Software License" | MIT, Apache-2.0, BSD |
|
||||
| LGPL | "GNU LESSER GENERAL PUBLIC LICENSE" | 弱 copyleft |
|
||||
| AGPL | "GNU AFFERO GENERAL PUBLIC LICENSE" | 严格 copyleft(网络传播) |
|
||||
| Unlicense | "Unlicense", "public domain" | 所有许可证 |
|
||||
| CC0 | "CC0", "Creative Commons Zero" | 公共领域 |
|
||||
|
||||
---
|
||||
|
||||
## 许可证兼容性矩阵
|
||||
|
||||
| 项目许可证 | 兼容的依赖许可证 | 不兼容的依赖许可证 |
|
||||
|-----------|-----------------|-------------------|
|
||||
| MIT | MIT, Apache-2.0, BSD-2/3, Unlicense, ISC, CC0, MulanPSL-2 | GPL-2/3 (仅分发时), AGPL |
|
||||
| Apache-2.0 | Apache-2.0, MIT, BSD-2/3, ISC, Unlicense | GPL-2/3 |
|
||||
| GPL-3.0 | GPL-3.0, MIT, Apache-2.0, BSD | —(兼容大多数) |
|
||||
| BSD-3 | MIT, BSD-2/3, Apache-2.0, ISC | GPL-2/3, AGPL |
|
||||
| MulanPSL-2 | MulanPSL-2, MIT, Apache-2.0, BSD | GPL-3 (需确认) |
|
||||
|
||||
---
|
||||
|
||||
## 合规检查清单
|
||||
|
||||
### 🔴 必须项检查
|
||||
|
||||
| 检查项 | 检查方法 | 通过标准 |
|
||||
|--------|----------|----------|
|
||||
| LICENSE 存在 | `raw/HEAD/LICENSE` != 404 | 文件存在且非空 |
|
||||
| 许可证有效 | 内容匹配已知许可证关键字 | 可识别为 OSI 批准的许可证 |
|
||||
| README 存在 | `sub_entries` 中 `is_readme_file=true` | 文件存在 |
|
||||
| .gitignore 存在 | `sub_entries` 中 name=".gitignore" | 文件存在 |
|
||||
|
||||
### 🟡 建议项检查
|
||||
|
||||
| 检查项 | 检查方法 | 通过标准 |
|
||||
|--------|----------|----------|
|
||||
| CONTRIBUTING.md | `sub_entries` 或 `raw` | 文件存在 |
|
||||
| SECURITY.md | `sub_entries` 或 `raw` | 文件存在 |
|
||||
| CODE_OF_CONDUCT.md | `sub_entries` 或 `raw` | 文件存在 |
|
||||
| CHANGELOG 或 Release | `sub_entries` 或 `release +list` | 任一存在 |
|
||||
|
||||
### 🔵 优化项检查
|
||||
|
||||
| 检查项 | 检查方法 | 通过标准 |
|
||||
|--------|----------|----------|
|
||||
| CI 配置 | 检查 `.trustie-pipeline.yml` / `.github/workflows` 等 | 存在即通过 |
|
||||
| 源文件版权头 | 采样检查源码文件前 5 行 | 50% 以上文件有版权声明 |
|
||||
| 依赖配置文件 | 检查 package.json / go.mod / Cargo.toml 等 | 存在即通过 |
|
||||
|
||||
---
|
||||
|
||||
## 输出格式规范
|
||||
|
||||
### 合规报告 Markdown 模板
|
||||
|
||||
```markdown
|
||||
## ⚖️ 合规检查报告 — <owner>/<repo>
|
||||
|
||||
📅 检查时间:<YYYY-MM-DD>
|
||||
📋 项目许可证:<识别到的许可证>
|
||||
|
||||
### 🔴 必须修复
|
||||
| # | 问题 | 文件 | 建议 |
|
||||
|---|------|------|------|
|
||||
|
||||
### 🟡 建议修复
|
||||
| # | 问题 | 文件 | 建议 |
|
||||
|---|------|------|------|
|
||||
|
||||
### 🔵 可选优化
|
||||
| # | 建议 | 说明 |
|
||||
|---|------|------|
|
||||
|
||||
### 📊 合规评分
|
||||
| 维度 | 状态 | 评分 |
|
||||
|------|:----:|:----:|
|
||||
| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ |
|
||||
| 🏷️ 版权声明 | ✅/⚠️/❌ | ☆☆☆☆☆ |
|
||||
| 📦 依赖合规 | ✅/⚠️/❌ | ☆☆☆☆☆ |
|
||||
| 🔒 安全策略 | ✅/⚠️/❌ | ☆☆☆☆☆ |
|
||||
| 📖 项目文档 | ✅/⚠️/❌ | ☆☆☆☆☆ |
|
||||
|
||||
**总体合规评分:<分数>/100**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 合规检查结果仅基于仓库中可读取的信息,不构成法律建议
|
||||
2. 许可证兼容性问题建议咨询法务或 OSPO
|
||||
3. 依赖分析需要根据项目语言选择对应的依赖文件
|
||||
4. 不同许可证的兼容性规则可能因 Jurisdiction 而异
|
||||
5. MulanPSL-2.0 是中国广泛使用的开源许可证,在 GitLink 平台上常见
|
||||
6. 版权声明检查为采样性质,100% 覆盖需专业扫描工具
|
||||
7. RAW API 返回的某些文件可能包含完整内容(`replace_content` 字段)
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
---
|
||||
name: gitlink-compliance
|
||||
version: 1.0.0
|
||||
description: "开源合规检查:扫描仓库许可证、版权声明、依赖合规性,生成合规报告与修复建议。当用户需要检查项目合规状态、许可证兼容性或准备开源发布时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli repo --help"
|
||||
---
|
||||
|
||||
# gitlink-compliance(开源合规检查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
---
|
||||
|
||||
## 工作流概览
|
||||
|
||||
本 Skill 提供开源项目的合规性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。
|
||||
|
||||
| 检查类型 | 覆盖范围 | 严重程度 |
|
||||
|----------|---------|:--------:|
|
||||
| 许可证检查 | LICENSE 文件存在性、许可证类型识别 | 🔴 / 🟡 |
|
||||
| 版权声明 | 源文件头部版权注释 | 🟡 |
|
||||
| 依赖合规 | 第三方依赖许可证兼容性 | 🔴 |
|
||||
| 安全策略 | SECURITY.md、安全披露流程 | 🟡 |
|
||||
| 贡献者协议 | CLA / DCO 要求 | 🔵 |
|
||||
|
||||
---
|
||||
|
||||
## 工作流 1:完整合规检查
|
||||
|
||||
**场景**:项目准备开源发布前,进行全面的合规性审查。
|
||||
|
||||
### 采集数据
|
||||
|
||||
```bash
|
||||
# 1. 获取仓库文件结构
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master'
|
||||
|
||||
# 2. 读取 LICENSE 文件
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/LICENSE
|
||||
|
||||
# 3. 检查关键文档是否存在
|
||||
# 检查以下文件是否存在:
|
||||
# - LICENSE / LICENSE.txt / LICENSE.md
|
||||
# - CONTRIBUTING / CONTRIBUTING.md
|
||||
# - SECURITY / SECURITY.md
|
||||
# - CODE_OF_CONDUCT / CODE_OF_CONDUCT.md
|
||||
# - .gitignore
|
||||
# - README.md
|
||||
|
||||
# 4. 获取依赖配置
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/package.json # Node.js
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/go.mod # Go
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/requirements.txt # Python
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/Cargo.toml # Rust
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/pom.xml # Java/Maven
|
||||
|
||||
# 5. 获取源文件检查(按语言采样)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
|
||||
|
||||
# 6. 获取仓库基本信息
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 检查清单
|
||||
|
||||
#### 🔴 必须修复项
|
||||
|
||||
| 检查项 | 标准 | 判定方法 |
|
||||
|--------|------|----------|
|
||||
| LICENSE 文件 | 根目录存在 LICENSE 文件且内容有效 | 检查文件是否存在、内容是否为空 |
|
||||
| 许可证类型 | 使用 OSI 批准的开放源码许可证 | 解析 LICENSE 内容,识别许可证类型 |
|
||||
| 许可证兼容性 | 项目许可证与依赖许可证兼容 | 检查依赖许可证,对比兼容性矩阵 |
|
||||
| 安全漏洞 | 依赖无已知 CVE | 检查依赖版本(需外部数据源) |
|
||||
|
||||
#### 🟡 建议修复项
|
||||
|
||||
| 检查项 | 标准 | 判定方法 |
|
||||
|--------|------|----------|
|
||||
| 版权声明 | 源文件头部包含版权和许可证信息 | 采样检查源文件头部注释 |
|
||||
| SECURITY.md | 存在安全策略披露流程 | 检查文件是否存在 |
|
||||
| CONTRIBUTING.md | 存在贡献指南 | 检查文件是否存在 |
|
||||
| 商标声明 | 正确使用项目/组织商标 | 检查 README 和 LICENSE 中的商标声明 |
|
||||
|
||||
#### 🔵 可选优化项
|
||||
|
||||
| 检查项 | 标准 | 判定方法 |
|
||||
|--------|------|----------|
|
||||
| CODE_OF_CONDUCT | 存在行为准则 | 检查文件是否存在 |
|
||||
| .gitignore | 存在且配置完整 | 检查文件是否存在、内容是否覆盖常见模式 |
|
||||
| README 质量 | README 包含安装、使用、贡献说明 | 检查内容长度和关键章节 |
|
||||
|
||||
### 输出格式
|
||||
|
||||
```markdown
|
||||
## ⚖️ 合规检查报告 — <owner>/<repo>
|
||||
|
||||
📅 检查时间:<YYYY-MM-DD>
|
||||
📋 项目许可证:<许可证类型>
|
||||
|
||||
### 🔴 必须修复
|
||||
|
||||
| # | 问题 | 文件 | 建议 |
|
||||
|---|------|------|------|
|
||||
| 1 | 缺少 LICENSE 文件 | — | 添加 LICENSE 文件,建议使用 <推荐许可证> |
|
||||
| 2 | 依赖 xxx 使用 GPL 许可证,与项目 MIT 许可证不兼容 | package.json | 替换为兼容许可证的替代库 |
|
||||
| 3 | 源文件缺少版权声明头 | src/*.py | 添加标准版权注释头 |
|
||||
|
||||
### 🟡 建议修复
|
||||
|
||||
| # | 问题 | 文件 | 建议 |
|
||||
|---|------|------|------|
|
||||
| 1 | 缺少 SECURITY.md | — | 添加安全漏洞披露流程文档 |
|
||||
| 2 | CONTRIBUTING.md 不存在 | — | 添加贡献指南 |
|
||||
|
||||
### 🔵 可选优化
|
||||
|
||||
| # | 建议 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | 添加 CODE_OF_CONDUCT.md | 规范社区行为准则 |
|
||||
| 2 | 完善 README 安装说明 | 当前缺少环境要求部分 |
|
||||
|
||||
### 📊 合规评分
|
||||
|
||||
| 维度 | 状态 | 评分 |
|
||||
|------|:----:|:----:|
|
||||
| 📜 许可证 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
|
||||
| 🏷️ 版权声明 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
|
||||
| 📦 依赖合规 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
|
||||
| 🔒 安全策略 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
|
||||
| 📖 项目文档 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
|
||||
|
||||
**总体合规评分:<分数>/100**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流 2:许可证兼容性检查
|
||||
|
||||
**场景**:检查项目使用的第三方依赖是否与项目许可证兼容。
|
||||
|
||||
```bash
|
||||
# 1. 获取依赖配置文件
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/package.json
|
||||
```
|
||||
|
||||
### 许可证兼容性参考
|
||||
|
||||
| 项目许可证 | 兼容的依赖许可证 | 不兼容的依赖许可证 |
|
||||
|-----------|-----------------|-------------------|
|
||||
| MIT | MIT, Apache-2.0, BSD-2/3, Unlicense, ISC, CC0 | GPL-2/3, AGPL |
|
||||
| Apache-2.0 | Apache-2.0, MIT, BSD-2/3, ISC, Unlicense | GPL-2/3 |
|
||||
| GPL-3.0 | GPL-3.0, MIT, Apache-2.0, BSD | — |
|
||||
| BSD-3 | MIT, BSD-2/3, Apache-2.0, ISC | GPL-2/3, AGPL |
|
||||
| MulanPSL-2 | MulanPSL-2, MIT, Apache-2.0, BSD | GPL-3, AGPL (需确认) |
|
||||
|
||||
### 输出格式
|
||||
|
||||
```bash
|
||||
# 依赖合规分析(示例输出结构)
|
||||
|
||||
## 📦 依赖合规分析
|
||||
|
||||
| 依赖 | 版本 | 许可证 | 兼容性 | 建议 |
|
||||
|------|:----:|:------:|:------:|------|
|
||||
| express | 4.18.2 | MIT | ✅ 兼容 | — |
|
||||
| lodash | 4.17.21 | MIT | ✅ 兼容 | — |
|
||||
| anticonflict-lib | 1.0.0 | GPL-3.0 | ❌ 不兼容 | 替换为兼容替代库 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流 3:版权声明批量检查
|
||||
|
||||
**场景**:检查项目中所有源文件是否包含正确的版权声明头。
|
||||
|
||||
```bash
|
||||
# 1. 遍历源文件目录
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
|
||||
|
||||
# 2. 采样检查源文件头部(取前 5-10 行)
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/src/main.py
|
||||
```
|
||||
|
||||
### 标准版权声明模板
|
||||
|
||||
```python
|
||||
# Copyright (c) <年份> <组织/作者>
|
||||
# Licensed under the <许可证> License.
|
||||
# See LICENSE file in the project root for full license information.
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 缺少头部注释 | 源文件没有版权/许可证信息 |
|
||||
| 年份过时 | 版权年份未更新到当前年份 |
|
||||
| 许可证不匹配 | 声明的许可证与实际 LICENSE 文件不一致 |
|
||||
| 组织名称错误 | 版权声明的组织名称与项目所属不一致 |
|
||||
|
||||
---
|
||||
|
||||
## Raw API 参考
|
||||
|
||||
```bash
|
||||
# 获取文件内容
|
||||
gitlink-cli api GET /:owner/:repo/raw/<branch>/<path>
|
||||
|
||||
# 获取文件列表(遍历目录)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
|
||||
|
||||
# 获取仓库信息
|
||||
gitlink-cli api GET /:owner/:repo --format json
|
||||
|
||||
# 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 合规检查结果仅基于仓库中可读取的文件,不构成法律建议
|
||||
- 对于许可证兼容性问题,建议在实际发布前咨询法务或开源办公室(OSPO)
|
||||
- 不同语言的依赖管理文件格式不同,需要根据项目主语言选择对应的依赖文件分析
|
||||
- 版权声明检查是采样性的,100% 覆盖需要运行专门的扫描工具
|
||||
- MulanPSL-2(木兰许可证)是 GitLink 平台上常用的许可证,需注意其与 GPL 的兼容性
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue