Compare commits
53 Commits
master
...
cli-probe-
| Author | SHA1 | Date |
|---|---|---|
|
|
7e84f89b2b | |
|
|
2a3333b19d | |
|
|
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 |
|
|
@ -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:
|
||||
|
|
@ -57,6 +57,10 @@ jobs:
|
|||
rm -rf "$BUILD_DIR"
|
||||
done
|
||||
|
||||
# Generate SHA256 checksums
|
||||
cd dist
|
||||
sha256sum *.tar.gz *.zip > checksums-sha256.txt
|
||||
cd ..
|
||||
ls -lh dist
|
||||
|
||||
- name: Create Release
|
||||
|
|
@ -65,8 +69,44 @@ jobs:
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,12 +15,17 @@ import (
|
|||
|
||||
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 +33,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())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持:
|
||||
新增 `webhook` Shortcut 组,支持
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,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
|
||||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/gitlink-org/gitlink-cli
|
||||
|
||||
go 1.26.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +135,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Method: method,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,13 +186,15 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
|||
func suggestFix(code int) string {
|
||||
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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -178,3 +179,53 @@ func formatValue(v interface{}) string {
|
|||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@
|
|||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
const PACKAGE = require("../package.json");
|
||||
const CURRENT_VERSION = PACKAGE.version;
|
||||
|
||||
function getBinaryName(platform = process.platform) {
|
||||
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
|
|
@ -36,6 +40,36 @@ function formatMissingBinaryError(
|
|||
].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;
|
||||
|
|
@ -49,10 +83,16 @@ function run(args = process.argv.slice(2), options = {}) {
|
|||
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) {
|
||||
|
|
@ -76,4 +116,5 @@ module.exports = {
|
|||
getBinaryPath,
|
||||
formatMissingBinaryError,
|
||||
run,
|
||||
checkForUpdate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,13 +14,14 @@ 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";
|
||||
|
||||
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",
|
||||
|
|
@ -54,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 ||
|
||||
|
|
@ -103,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"));
|
||||
});
|
||||
|
|
@ -122,14 +193,12 @@ 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;
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
|
|
@ -137,7 +206,6 @@ async function findReleaseAsset(platform, arch) {
|
|||
(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) {
|
||||
|
|
@ -151,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}`;
|
||||
|
|
@ -164,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;
|
||||
}
|
||||
|
|
@ -177,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);
|
||||
|
|
@ -192,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
|
||||
|
|
@ -208,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);
|
||||
|
|
@ -223,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);
|
||||
}
|
||||
|
|
@ -251,22 +361,58 @@ 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}`);
|
||||
}
|
||||
|
|
@ -292,4 +438,6 @@ module.exports = {
|
|||
getBinaryName,
|
||||
getArchiveName,
|
||||
findReleaseAsset,
|
||||
classifyError,
|
||||
verifyChecksum,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "upload",
|
||||
Description: "Upload a file attachment",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a file attachment by 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,146 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "Delete multiple branches",
|
||||
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{
|
||||
Name: "batch-protect",
|
||||
Description: "Set branch protection for multiple branches",
|
||||
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 ---
|
||||
|
||||
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 ---
|
||||
|
||||
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", ctx.RepoPath()+"/protected_branches", 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
|
||||
}
|
||||
|
|
@ -9,9 +9,20 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchDeleteShortcut(),
|
||||
newBatchProtectShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List branches",
|
||||
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"},
|
||||
|
|
@ -33,9 +44,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
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 {
|
||||
|
|
@ -44,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
from = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"new_branch_name": name,
|
||||
|
|
@ -60,6 +80,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
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 +93,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,
|
||||
}
|
||||
|
|
@ -81,6 +111,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
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},
|
||||
},
|
||||
|
|
@ -102,6 +137,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
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},
|
||||
},
|
||||
|
|
@ -117,5 +157,61 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "all",
|
||||
Description: "List all branches without pagination",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "default",
|
||||
Description: "Update the default branch",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "restore",
|
||||
Description: "Restore a deleted branch",
|
||||
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
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,39 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "builds",
|
||||
Description: "List CI builds",
|
||||
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 +53,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
|
||||
|
|
@ -33,6 +78,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "logs",
|
||||
Description: "View build logs",
|
||||
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"},
|
||||
|
|
@ -61,6 +115,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "restart",
|
||||
Description: "Restart a build",
|
||||
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},
|
||||
},
|
||||
|
|
@ -79,6 +138,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "stop",
|
||||
Description: "Stop a build",
|
||||
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 +151,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 +163,208 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Pipeline / Actions group ---
|
||||
|
||||
{
|
||||
Name: "pipelines",
|
||||
Description: "List CI pipeline templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run-pipeline",
|
||||
Description: "Run a CI pipeline",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "runs",
|
||||
Description: "List CI run history",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "save-yaml",
|
||||
Description: "Save a pipeline YAML configuration",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pipeline-detail",
|
||||
Description: "Show pipeline details",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-pipeline",
|
||||
Description: "Delete a pipeline",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disable",
|
||||
Description: "Disable CI for the repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "enable",
|
||||
Description: "Enable CI for the repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run-log",
|
||||
Description: "Get CI run log",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run-results",
|
||||
Description: "Get CI run results report",
|
||||
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,315 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// userRole holds a username and the role to assign.
|
||||
type userRole struct {
|
||||
User string
|
||||
Role string
|
||||
}
|
||||
|
||||
func newBatchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-add",
|
||||
Description: "Add multiple collaborators to the repository",
|
||||
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{
|
||||
Name: "batch-remove",
|
||||
Description: "Remove multiple collaborators from the repository",
|
||||
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{
|
||||
Name: "batch-role",
|
||||
Description: "Change role for multiple collaborators",
|
||||
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 ---
|
||||
|
||||
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 ---
|
||||
|
||||
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 ---
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(items, dryRun, "change-role", 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("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 builds a deduplicated slice of userRole pairs from
|
||||
// --users (combined with defaultRole) and/or --from CSV.
|
||||
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 builds a deduplicated slice of usernames from --users and/or --from CSV.
|
||||
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 looks up the role assigned to a user in the pairs slice.
|
||||
func roleForUser(pairs []userRole, user string) string {
|
||||
for _, p := range pairs {
|
||||
if p.User == user {
|
||||
return p.Role
|
||||
}
|
||||
}
|
||||
return "write"
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return append([]*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository collaborators",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add",
|
||||
Description: "Add a collaborator to the repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "Remove a collaborator from the repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "change-role",
|
||||
Description: "Change a collaborator's role",
|
||||
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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": 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)
|
||||
},
|
||||
},
|
||||
},
|
||||
newBatchAddShortcut(),
|
||||
newBatchRemoveShortcut(),
|
||||
newBatchRoleShortcut(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository commits",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View commit details including changed files",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "View the diff of a commit",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "Get blame information for a file",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "compare",
|
||||
Description: "Compare two refs (branches, tags, or SHAs)",
|
||||
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,281 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Result structures ---
|
||||
|
||||
// BatchResult records the outcome of a single item in a batch operation.
|
||||
type BatchResult struct {
|
||||
Item string `json:"item" yaml:"item"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"` // "planned", "ok", "failed"
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BatchSummary is the unified output of every batch command.
|
||||
type BatchSummary 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 []BatchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
// --- Input collection ---
|
||||
|
||||
// CollectNumbers collects integer identifiers from --numbers and --from CSV.
|
||||
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 collects string identifiers (e.g. branch names) from --names and --from file.
|
||||
// The file is read one entry per line (plain text, not 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 parses a comma-separated list of integers.
|
||||
func ParseNumberList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NormalizeIntStrings(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
// ParseStringList parses a comma-separated list of strings (trimmed).
|
||||
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 reads a single column from a CSV file.
|
||||
// columnNames lists acceptable header names; if none match, the first column is used.
|
||||
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 reads multiple columns from a CSV file.
|
||||
// Returns a slice of maps keyed by the required column names.
|
||||
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
|
||||
}
|
||||
|
||||
// Map column name → column index from the header row
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all required columns found
|
||||
for _, req := range requiredCols {
|
||||
if _, ok := colIndex[req]; !ok {
|
||||
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
|
||||
}
|
||||
}
|
||||
|
||||
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 reads non-empty lines from a plain-text file.
|
||||
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 validates that every value is an integer and deduplicates.
|
||||
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
|
||||
}
|
||||
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 deduplicates strings while preserving order.
|
||||
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
|
||||
}
|
||||
|
||||
// --- Batch processing loop ---
|
||||
|
||||
// ProcessBatch runs fn on each item. On dry-run it marks items as "planned".
|
||||
// Single-item failure does not stop the loop; the caller gets a full summary.
|
||||
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)),
|
||||
}
|
||||
for _, item := range items {
|
||||
r := BatchResult{Item: item, Action: action}
|
||||
if dryRun {
|
||||
r.Status = "planned"
|
||||
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.Results = append(summary.Results, r)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
// ParseBool parses a boolean string.
|
||||
func ParseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
// ConfirmAction prompts the user for confirmation before a destructive action.
|
||||
// Returns nil if confirmed, error if cancelled.
|
||||
// Skipped automatically when --yes flag is set.
|
||||
func ConfirmAction(action string) error {
|
||||
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")
|
||||
}
|
||||
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,25 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
|||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetDefaultBranch queries the repo API and returns the default branch name.
|
||||
// Falls back to "master" if the API call fails or the field is not present.
|
||||
func GetDefaultBranch(ctx *RuntimeContext) string {
|
||||
if ctx.Owner == "" || ctx.Repo == "" {
|
||||
return "master"
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return "master"
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "master"
|
||||
}
|
||||
for _, key := range []string{"default_branch", "defaultBranch"} {
|
||||
if db, ok := data[key].(string); ok && db != "" {
|
||||
return db
|
||||
}
|
||||
}
|
||||
return "master"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
package dataset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a dataset in a repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a dataset in a repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View dataset details for a repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List project datasets",
|
||||
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,420 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "get",
|
||||
Description: "Get file contents from repository",
|
||||
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
|
||||
}
|
||||
apiPath := fmt.Sprintf("/%s/%s/files", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"filepath": filePath,
|
||||
"ref": ctx.Arg("ref"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "Get the repository 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new file in the repository",
|
||||
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)"},
|
||||
},
|
||||
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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an existing file in the repository",
|
||||
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"},
|
||||
{Name: "sha", Usage: "Original file SHA (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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if sha := ctx.Arg("sha"); sha != "" {
|
||||
payload["sha"] = sha
|
||||
}
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a file from the repository",
|
||||
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"},
|
||||
{Name: "sha", Usage: "File SHA (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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if sha := ctx.Arg("sha"); sha != "" {
|
||||
payload["sha"] = sha
|
||||
}
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tree",
|
||||
Description: "Get a git tree for a given SHA",
|
||||
Long: `Get a git tree for a given SHA.
|
||||
|
||||
Returns the directory listing (tree object) at the specified SHA.
|
||||
Use --recursive to fetch the entire tree recursively.`,
|
||||
Example: ` # Get a git tree
|
||||
gitlink file +tree --sha abc123def456
|
||||
|
||||
# Get a recursive tree
|
||||
gitlink file +tree --sha abc123def456 --recursive`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Tree SHA", Required: true},
|
||||
{Name: "recursive", Usage: "Fetch recursively", Bool: 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)
|
||||
query := buildQuery(map[string]string{
|
||||
"recursive": ctx.Arg("recursive"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "blob",
|
||||
Description: "Get a git blob by SHA",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch",
|
||||
Description: "Create or update multiple files in a single commit",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "entries",
|
||||
Description: "Get repository entries (directory listing)",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sub-entries",
|
||||
Description: "Get repository sub-entries",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "replace",
|
||||
Description: "Replace a file in the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (will be base64-encoded)", 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
|
||||
}
|
||||
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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/replace_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,37 +1,90 @@
|
|||
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 fetches all issues and maps user-facing project_issues_index
|
||||
// values to their internal database IDs. Returns a slice of interface{} values
|
||||
// suitable for JSON serialization in batch API calls.
|
||||
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")
|
||||
}
|
||||
|
||||
// Build mapping: project_issues_index (as string) → database id
|
||||
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"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, ok := issue["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
indexToID[fmt.Sprintf("%v", idx)] = 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{
|
||||
Name: "batch-close",
|
||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||||
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"},
|
||||
|
|
@ -46,7 +99,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
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 +107,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
|
||||
|
|
@ -108,105 +139,187 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-update",
|
||||
Description: "Update multiple issues at once using the native batch API",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
Name: "batch-destroy",
|
||||
Description: "Delete multiple issues at once using the native batch API",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,46 @@ type existingIssue struct {
|
|||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDestroyShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
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 {
|
||||
|
|
@ -41,6 +74,24 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("fixed_version_id", m)
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assigned_to_id", a)
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
q.Set("issue_tag_id", l)
|
||||
}
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k)
|
||||
}
|
||||
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", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -52,6 +103,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a new issue",
|
||||
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"},
|
||||
|
|
@ -82,6 +145,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -92,6 +158,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "view",
|
||||
Description: "View issue details",
|
||||
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},
|
||||
},
|
||||
|
|
@ -113,8 +188,22 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "close",
|
||||
Description: "Close an issue",
|
||||
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 {
|
||||
|
|
@ -138,17 +227,49 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Optionally post a closing comment
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue",
|
||||
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 {
|
||||
|
|
@ -161,8 +282,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)
|
||||
|
|
@ -187,6 +313,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l
|
||||
}
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
body["priority_id"] = p
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -197,6 +335,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to an issue",
|
||||
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},
|
||||
|
|
@ -223,6 +369,199 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "List comments and activity on an issue",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "Update a comment on an issue",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "Delete a comment on an issue",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reply-comment",
|
||||
Description: "List child replies of a comment",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch-update",
|
||||
Description: "Batch update multiple issues",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch-destroy",
|
||||
Description: "Batch delete multiple issues",
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,3 +628,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,210 @@
|
|||
package label
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository issue labels (tags)",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create an issue label",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue label",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an issue label",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "priorities",
|
||||
Description: "List available issue priorities",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "statuses",
|
||||
Description: "List available issue statuses",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authors",
|
||||
Description: "List available issue authors",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "List available issue assigners",
|
||||
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,218 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository milestones",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a milestone",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View milestone details",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a milestone",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a milestone",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "close",
|
||||
Description: "Close a milestone",
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
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"},
|
||||
|
|
@ -30,6 +39,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "info",
|
||||
Description: "Show organization details",
|
||||
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},
|
||||
},
|
||||
|
|
@ -45,6 +63,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "members",
|
||||
Description: "List organization members",
|
||||
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"},
|
||||
|
|
@ -65,6 +92,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create an organization",
|
||||
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 +120,39 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add-team-projects",
|
||||
Description: "Add all projects to a team",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-team-projects",
|
||||
Description: "Remove all projects from a team",
|
||||
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,158 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-close",
|
||||
Description: "Close multiple pull requests",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
Name: "batch-merge",
|
||||
Description: "Merge multiple pull requests",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchMerge(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(numbers, false, "merge", func(number string) error {
|
||||
payload := map[string]interface{}{
|
||||
"do": method,
|
||||
}
|
||||
_, 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,50 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchMergeShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List pull requests",
|
||||
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 +64,27 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k)
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("version_id", m)
|
||||
}
|
||||
if t := ctx.Arg("tag"); t != "" {
|
||||
q.Set("issue_tag_id", t)
|
||||
}
|
||||
if r := ctx.Arg("reviewer"); r != "" {
|
||||
q.Set("reviewer_id", r)
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assign_user_id", a)
|
||||
}
|
||||
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
|
||||
|
|
@ -38,11 +95,32 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a pull request",
|
||||
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 {
|
||||
|
|
@ -52,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
base = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
|
|
@ -62,6 +140,28 @@ func Shortcuts() []*common.Shortcut {
|
|||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
payload["priority_id"] = p
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
payload["assigned_to_id"] = a
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
payload["fixed_version_id"] = m
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
// Support comma-separated label IDs
|
||||
if strings.Contains(l, ",") {
|
||||
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
|
||||
|
|
@ -72,6 +172,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "view",
|
||||
Description: "View pull request details",
|
||||
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},
|
||||
},
|
||||
|
|
@ -90,9 +196,26 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
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 +226,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
|
||||
|
|
@ -116,6 +248,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "close",
|
||||
Description: "Close a pull request",
|
||||
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},
|
||||
},
|
||||
|
|
@ -134,24 +272,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "files",
|
||||
Description: "List changed files in a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a pull request",
|
||||
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},
|
||||
},
|
||||
|
|
@ -170,6 +296,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "versions",
|
||||
Description: "List pull request patchset versions",
|
||||
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},
|
||||
},
|
||||
|
|
@ -191,6 +323,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "version-diff",
|
||||
Description: "Show diff for a pull request patchset version",
|
||||
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},
|
||||
|
|
@ -228,6 +368,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "reviews",
|
||||
Description: "List pull request reviews",
|
||||
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"},
|
||||
|
|
@ -257,6 +406,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "review",
|
||||
Description: "Create a pull request review",
|
||||
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"},
|
||||
|
|
@ -306,9 +468,210 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "List review comments on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-comment",
|
||||
Description: "Create a review comment on a pull request",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "Update a review comment on a pull request",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "Delete a review comment on a pull request",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "commits",
|
||||
Description: "List commits in a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reopen",
|
||||
Description: "Reopen a closed pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update pull request title and/or 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to a pull request",
|
||||
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},
|
||||
|
|
@ -355,6 +718,15 @@ func validatePRReviewStatus(status string) error {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -263,6 +263,283 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 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,45 +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(),
|
||||
"webhook": webhook.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",
|
||||
"webhook": "Webhook 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 {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,28 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "list",
|
||||
Description: "List releases",
|
||||
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 +47,36 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// --prerelease-only: client-side filter
|
||||
if ctx.Arg("prerelease-only") == "true" {
|
||||
filterReleasesByPrerelease(env)
|
||||
}
|
||||
// --latest: keep only the first (most recent) release
|
||||
if ctx.Arg("latest") == "true" {
|
||||
keepOnlyLatestRelease(env)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a release",
|
||||
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 {
|
||||
|
|
@ -56,6 +94,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
if t := ctx.Arg("target"); t != "" {
|
||||
payload["target_commitish"] = t
|
||||
} else {
|
||||
payload["target_commitish"] = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
if ctx.Arg("prerelease") == "true" {
|
||||
payload["prerelease"] = true
|
||||
|
|
@ -70,6 +110,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "view",
|
||||
Description: "View release details",
|
||||
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},
|
||||
},
|
||||
|
|
@ -88,6 +136,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a release",
|
||||
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 +149,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 +162,118 @@ 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))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit",
|
||||
Description: "Get release edit page details",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a release",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New release name"},
|
||||
{Name: "body", Short: "b", Usage: "New release 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,8 +1,10 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
|
@ -12,6 +14,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
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"},
|
||||
|
|
@ -41,6 +56,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "info",
|
||||
Description: "Show repository details",
|
||||
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
|
||||
|
|
@ -55,6 +75,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a new repository",
|
||||
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"},
|
||||
|
|
@ -97,6 +126,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "fork",
|
||||
Description: "Fork a repository",
|
||||
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
|
||||
|
|
@ -111,10 +145,21 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a repository",
|
||||
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 +167,529 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Settings/Info group ---
|
||||
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update repository settings",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "about",
|
||||
Description: "Show repository about page",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "menu",
|
||||
Description: "List repository navigation menu",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "units-get",
|
||||
Description: "Get repository navigation settings",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "units-set",
|
||||
Description: "Update repository navigation settings",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit-detail",
|
||||
Description: "Get repository edit details",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "simple",
|
||||
Description: "Get repository simplified details",
|
||||
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 ---
|
||||
|
||||
{
|
||||
Name: "code-stats",
|
||||
Description: "Get repository code statistics",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "languages",
|
||||
Description: "Get repository language breakdown",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "contributors",
|
||||
Description: "List repository contributors",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "contributors-stat",
|
||||
Description: "Get contributor code statistics",
|
||||
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 ---
|
||||
|
||||
{
|
||||
Name: "recommend",
|
||||
Description: "Get recommended projects",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "star",
|
||||
Description: "Star (like) a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unstar",
|
||||
Description: "Unstar (unlike) a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Watch (follow) a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "Unwatch (unfollow) a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stargazers",
|
||||
Description: "List users who starred the repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "List users watching the repository",
|
||||
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 ---
|
||||
|
||||
{
|
||||
Name: "transfer",
|
||||
Description: "Transfer repository to another owner",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cancel-transfer",
|
||||
Description: "Cancel a pending repository transfer",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transfer-orgs",
|
||||
Description: "List organizations available for transfer",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite-link",
|
||||
Description: "Get current repository invite link",
|
||||
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 ---
|
||||
|
||||
{
|
||||
Name: "join",
|
||||
Description: "Apply to join a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "quit",
|
||||
Description: "Quit a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "Create a mirrored project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sync-mirror",
|
||||
Description: "Sync a mirrored repository",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "topics",
|
||||
Description: "List project topics",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-topic",
|
||||
Description: "Create a project topic",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-topic",
|
||||
Description: "Delete a project topic",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,29 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "repos",
|
||||
Description: "Search repositories",
|
||||
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 +41,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
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
|
||||
|
|
@ -32,10 +60,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "users",
|
||||
Description: "Search users",
|
||||
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 +86,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
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,267 @@
|
|||
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{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List code snippets in the repository",
|
||||
Run: runList,
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new code snippet",
|
||||
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,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a code snippet",
|
||||
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,93 @@
|
|||
package sshkey
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List your SSH public keys",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Add a new SSH public key",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an SSH public key",
|
||||
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,101 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository tags",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View tag details",
|
||||
Long: `Show detailed information about a specific tag.
|
||||
|
||||
Displays the tag name, associated commit, message, and tagger details.`,
|
||||
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.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a tag",
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List project templates for an owner",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a project template",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a project template",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a project template",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a project template",
|
||||
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,7 +1,9 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
|
@ -11,6 +13,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "me",
|
||||
Description: "Show current authenticated user",
|
||||
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 {
|
||||
|
|
@ -22,6 +30,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "info",
|
||||
Description: "Show user profile",
|
||||
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 +51,732 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Message management ---
|
||||
{
|
||||
Name: "messages",
|
||||
Description: "List user messages",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-message",
|
||||
Description: "Delete a message",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-message",
|
||||
Description: "Create a message",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read-message",
|
||||
Description: "Mark a message as read",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "msg-settings",
|
||||
Description: "Get message notification settings",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-msg-settings",
|
||||
Description: "Update message notification settings",
|
||||
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 ---
|
||||
{
|
||||
Name: "pinned-projects",
|
||||
Description: "List pinned projects",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pin-project",
|
||||
Description: "Pin a project",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reorder-pinned",
|
||||
Description: "Reorder a pinned project",
|
||||
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 ---
|
||||
{
|
||||
Name: "activity",
|
||||
Description: "Get user activity statistics",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "headmap",
|
||||
Description: "Get user heatmap data",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "develop-stats",
|
||||
Description: "Get user development statistics",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "role-stats",
|
||||
Description: "Get user role statistics",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "major-stats",
|
||||
Description: "Get user major statistics",
|
||||
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 ---
|
||||
{
|
||||
Name: "applied-transfers",
|
||||
Description: "List applied transfer projects",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "accept-transfer",
|
||||
Description: "Accept a project transfer",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "refuse-transfer",
|
||||
Description: "Refuse a project transfer",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "applied-projects",
|
||||
Description: "List applied projects (join requests)",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "accept-join",
|
||||
Description: "Accept a project join request",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "refuse-join",
|
||||
Description: "Refuse a project join request",
|
||||
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 ---
|
||||
{
|
||||
Name: "feedback",
|
||||
Description: "Submit feedback",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update user profile",
|
||||
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 ---
|
||||
{
|
||||
Name: "change-password",
|
||||
Description: "Change password",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-email",
|
||||
Description: "Update email address",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-phone",
|
||||
Description: "Update phone number",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send-verify",
|
||||
Description: "Send email verification code",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check-verify",
|
||||
Description: "Check email verification code",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check-password",
|
||||
Description: "Verify password",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check-email",
|
||||
Description: "Verify email",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "can-delete",
|
||||
Description: "Check if user account can be deleted",
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete (deactivate) user account",
|
||||
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,43 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "licenses",
|
||||
Description: "List available open source license templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/licenses", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ignores",
|
||||
Description: "List available .gitignore templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/ignores", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "platform-msg-settings",
|
||||
Description: "Show platform-wide message notification settings",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package webhook
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -27,6 +28,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "list",
|
||||
Description: "List repository webhooks",
|
||||
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
|
||||
|
|
@ -41,6 +47,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "view",
|
||||
Description: "View webhook details",
|
||||
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},
|
||||
},
|
||||
|
|
@ -62,6 +74,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "create",
|
||||
Description: "Create a repository webhook",
|
||||
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},
|
||||
|
|
@ -77,6 +99,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "update",
|
||||
Description: "Update a repository webhook while preserving unspecified fields when available",
|
||||
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"},
|
||||
|
|
@ -93,6 +125,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a repository webhook",
|
||||
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},
|
||||
},
|
||||
|
|
@ -114,6 +151,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "test",
|
||||
Description: "Trigger a test delivery for a webhook",
|
||||
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},
|
||||
},
|
||||
|
|
@ -135,6 +177,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "tasks",
|
||||
Description: "List webhook delivery tasks",
|
||||
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},
|
||||
},
|
||||
|
|
@ -153,6 +201,113 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "failed",
|
||||
Description: "List failed webhook deliveries",
|
||||
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),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "task-view",
|
||||
Description: "View webhook delivery task details",
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,548 @@
|
|||
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{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List wiki pages in a repository",
|
||||
Run: runList,
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View wiki page content",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a wiki page",
|
||||
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,
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a wiki page",
|
||||
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,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
Description: "Export wiki pages",
|
||||
Flags: []common.Flag{
|
||||
{Name: "project-id", Usage: "Project ID (numeric)"},
|
||||
},
|
||||
Run: runExport,
|
||||
},
|
||||
{
|
||||
Name: "import",
|
||||
Description: "Import wiki pages from file",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
name: gitlink-issue-triage
|
||||
version: 1.0.0
|
||||
description: "Issue 自动分拣:根据 Issue 内容自动分类、打标签、分配责任人、添加引导评论。当用户需要自动处理新提交的 Issue 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli issue --help"
|
||||
---
|
||||
|
||||
# gitlink-issue-triage(Issue 自动分拣)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
|
||||
## 说明
|
||||
|
||||
本 Skill 组合多个 gitlink-cli 命令,实现 Issue 的自动化分类和处理流程:
|
||||
|
||||
1. 获取新提交的 Issue 列表
|
||||
2. 根据 Issue 内容(标题 + 描述)自动判断类别
|
||||
3. 为 Issue 打上对应标签、分配责任人
|
||||
4. 添加引导评论
|
||||
|
||||
## 依赖的 Shortcuts
|
||||
|
||||
| Shortcut | 用途 |
|
||||
|----------|------|
|
||||
| `issue +list` | 获取待处理的 Issue 列表 |
|
||||
| `issue +view` | 查看 Issue 详细内容 |
|
||||
| `issue +update` | 修改 Issue 状态/标签 |
|
||||
| `issue +comment` | 添加评论 |
|
||||
| `label +list` | 查看可用标签 |
|
||||
| `user +info` | 查询用户信息 |
|
||||
|
||||
## 处理流程
|
||||
|
||||
```
|
||||
1. issue +list --state open → 获取所有打开的 Issue
|
||||
2. issue +view --number {id} → 查看 Issue 详情
|
||||
3. 分析 subject + description → 判断 Issue 类别
|
||||
4. label +list → 查看可用标签
|
||||
5. issue +update --label {label} → 打标签
|
||||
6. issue +comment --body "..." → 添加引导评论
|
||||
```
|
||||
|
||||
## 分类规则参考
|
||||
|
||||
| 类别 | 标题关键词 | 建议标签 | 建议负责人 |
|
||||
|------|-----------|---------|-----------|
|
||||
| Bug | 错误、失败、异常、bug、crash、报错 | bug | 项目维护者 |
|
||||
| 功能需求 | 建议、希望、需要、feature、支持 | enhancement | PM |
|
||||
| 文档 | 文档、README、文档缺失、拼写 | documentation | 文档负责人 |
|
||||
| 问题咨询 | 请问、怎么、如何、help、question | question | 社区支持 |
|
||||
| 性能 | 慢、卡顿、性能、优化、performance | performance | 核心开发者 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 1. 获取所有打开的 Issue
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state open --format json
|
||||
|
||||
# 2. 查看某个 Issue 的详细内容
|
||||
gitlink-cli issue +view --owner myuser --repo myrepo --number 5 --format json
|
||||
|
||||
# 3. 查看仓库可用标签
|
||||
gitlink-cli label +list --owner myuser --repo myrepo
|
||||
|
||||
# 4. 为 Issue 打标签(假设判断为 bug)
|
||||
gitlink-cli issue +update --owner myuser --repo myrepo --number 5 --state open
|
||||
|
||||
# 5. 添加分类引导评论
|
||||
gitlink-cli issue +comment --owner myuser --repo myrepo --number 5 --body "感谢提交 Issue!已自动分类为 **Bug**,请补充以下信息:
|
||||
|
||||
- 运行环境(操作系统、版本)
|
||||
- 复现步骤
|
||||
- 期望行为与实际行为
|
||||
|
||||
项目维护者会尽快处理。"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 每次操作前先列出当前待处理的 Issue,评估数量。
|
||||
- 添加评论前先确认 Issue 内容,避免误分类。
|
||||
- 标签名称需要先在项目中确认是否存在,不存在则跳过。
|
||||
- 分配责任人前需确认该用户是否为项目成员。
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# Issue 自动分拣完整工作流示例
|
||||
|
||||
**场景**:项目收到一个新 Issue,需要自动判断类别、打标签、添加引导评论。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `gitlink-cli` 已安装并登录
|
||||
- 对目标仓库有写入权限
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### Step 1:查看待处理的 Issue
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```json
|
||||
{
|
||||
"issues": [
|
||||
{
|
||||
"number": 10,
|
||||
"subject": "登录页面报错:500 Internal Server Error",
|
||||
"status_id": 1,
|
||||
"author": {"login": "newuser"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2:查看 Issue 详细内容
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
|
||||
```
|
||||
|
||||
### Step 3:分析 Issue 内容判断类别
|
||||
|
||||
根据标题和描述中的关键词判断:
|
||||
|
||||
| 关键词 | 类别 |
|
||||
|--------|------|
|
||||
| 报错、错误、bug | Bug |
|
||||
| 建议、希望 | 功能需求 |
|
||||
| 文档 | 文档 |
|
||||
| 请问、怎么 | 问题咨询 |
|
||||
|
||||
### Step 4:添加分类评论
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 10 --body "感谢提交 Issue!已自动分类为 **Bug**。
|
||||
|
||||
请补充以下信息以便排查:
|
||||
- 运行环境(操作系统、浏览器版本)
|
||||
- 复现步骤
|
||||
- 错误截图或日志
|
||||
|
||||
项目维护者会尽快处理。"
|
||||
```
|
||||
|
||||
### Step 5:确认处理结果
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整命令速览
|
||||
|
||||
```bash
|
||||
# 1. 获取待处理 Issue
|
||||
gitlink-cli issue +list --state open
|
||||
|
||||
# 2. 查看详情
|
||||
gitlink-cli issue +view --number <id>
|
||||
|
||||
# 3. 添加分类评论
|
||||
gitlink-cli issue +comment --number <id> --body "<评论内容>"
|
||||
```
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
---
|
||||
name: gitlink-newcomer-guide
|
||||
version: 1.0.0
|
||||
description: "新人引导:为 good-first-issue 自动添加引导评论,降低新贡献者参与门槛。当项目收到新贡献者或标记了 good-first-issue 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli issue --help"
|
||||
---
|
||||
|
||||
# gitlink-newcomer-guide(新人引导)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
|
||||
## 说明
|
||||
|
||||
本 Skill 帮助项目维护者为新贡献者创建友好的入门体验。自动识别标记为 good-first-issue 的 Issue,并为其添加结构化的引导评论,降低新贡献者的参与门槛。
|
||||
|
||||
## 依赖的 Shortcuts
|
||||
|
||||
| Shortcut | 用途 |
|
||||
|----------|------|
|
||||
| `issue +list` | 获取标记了 good-first-issue 的 Issue |
|
||||
| `issue +view` | 查看 Issue 详细内容 |
|
||||
| `issue +comment` | 添加引导评论 |
|
||||
| `label +list` | 查看可用标签 |
|
||||
| `repo +info` | 获取仓库基本信息 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
1. issue +list → 获取所有打开的 Issue
|
||||
2. 筛选含 good-first-issue 标签的 Issue
|
||||
3. issue +view --number {id} → 查看 Issue 详情
|
||||
4. 分析 Issue 内容 → 判断适合的引导信息
|
||||
5. issue +comment --body "..." → 添加引导评论
|
||||
6. 可选:issue +update → 分配 mentor
|
||||
```
|
||||
|
||||
## 引导评论模板
|
||||
|
||||
根据 Issue 类型选择合适的引导评论:
|
||||
|
||||
### Bug 类 Issue
|
||||
|
||||
```markdown
|
||||
你好!欢迎参与贡献 🎉
|
||||
|
||||
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
|
||||
|
||||
## 如何开始
|
||||
|
||||
1. 在评论区留言"我想认领这个 Issue",我们会把你添加到 Assignee
|
||||
2. 查看 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解开发环境搭建
|
||||
3. Fork 本仓库并创建你的功能分支
|
||||
4. 修复问题后提交 Pull Request
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- 如果对问题描述有疑问,可以在下方留言
|
||||
- 在开发过程中遇到任何问题,也欢迎随时提问
|
||||
|
||||
祝你编码愉快!
|
||||
```
|
||||
|
||||
### 功能需求类 Issue
|
||||
|
||||
```markdown
|
||||
你好!欢迎参与贡献 🎉
|
||||
|
||||
这个 Issue 被标记为 **good first issue**,适合作为你了解本项目的起点。
|
||||
|
||||
## 实现思路
|
||||
|
||||
1. 建议先阅读相关模块的源代码,了解现有实现
|
||||
2. 可以参考已有的类似功能实现
|
||||
3. 记得添加对应的单元测试
|
||||
|
||||
## 提交流程
|
||||
|
||||
1. Fork 仓库并创建分支
|
||||
2. 实现功能并编写测试
|
||||
3. 确保 `go test ./...` 全部通过
|
||||
4. 提交 Pull Request,描述你的改动
|
||||
|
||||
如有任何问题,随时留言!
|
||||
```
|
||||
|
||||
### 文档类 Issue
|
||||
|
||||
```markdown
|
||||
你好!欢迎参与贡献 🎉
|
||||
|
||||
感谢你对改进文档的兴趣!
|
||||
|
||||
## 文档贡献指南
|
||||
|
||||
1. 文档位于项目中的 `docs/` 目录下
|
||||
2. 使用 Markdown 格式编写
|
||||
3. 修改后请预览效果,确保格式正确
|
||||
4. 提交 Pull Request,描述你的改动
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 保持文档风格一致
|
||||
- 中英文之间保留空格
|
||||
- 代码块请标注语言类型
|
||||
|
||||
期待你的贡献!
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 1. 查找所有标记了 good-first-issue 的 Issue
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state open --format json
|
||||
|
||||
# 2. 查看某个 Issue 的详细内容(判断类型)
|
||||
gitlink-cli issue +view --owner myuser --repo myrepo --number 10 --format json
|
||||
|
||||
# 3. 查看仓库可用标签
|
||||
gitlink-cli label +list --owner myuser --repo myrepo
|
||||
|
||||
# 4. 添加引导评论
|
||||
gitlink-cli issue +comment --owner myuser --repo myrepo --number 10 --body "你好!欢迎参与贡献..."
|
||||
|
||||
# 5. 可选:将 Issue 标记为已分配
|
||||
gitlink-cli issue +update --owner myuser --repo myrepo --number 10
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 添加引导评论前,先检查该 Issue 是否已有引导评论(避免重复)。
|
||||
- 如果 Issue 已经被 Assignee 认领,不需要再添加引导评论。
|
||||
- 不要修改 good-first-issue 标签,由项目维护者管理。
|
||||
- 引导评论建议使用温和、鼓励的语气。
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# 新人引导完整工作流示例
|
||||
|
||||
**场景**:项目标记了一个 good-first-issue,需要为新贡献者添加引导评论,帮助 TA 迈出第一步。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `gitlink-cli` 已安装并登录
|
||||
- 对目标仓库有评论权限
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### Step 1:查找 good-first-issue
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
|
||||
```
|
||||
|
||||
查看 Issue 列表中哪些标记了适合新人的任务。
|
||||
|
||||
### Step 2:查看 Issue 详情
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
|
||||
```
|
||||
|
||||
分析 Issue 内容:
|
||||
- 是否是 Bug 修复?→ 使用 Bug 模板
|
||||
- 是否是文档改进?→ 使用文档模板
|
||||
- 是否是功能需求?→ 使用功能模板
|
||||
|
||||
### Step 3:添加引导评论
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 10 --body "你好!欢迎参与贡献 🎉
|
||||
|
||||
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
|
||||
|
||||
### 如何开始
|
||||
1. 在评论区留言"我想认领",我们会把你添加到 Assignee
|
||||
2. Fork 本仓库并创建你的功能分支
|
||||
3. 修改代码后提交 Pull Request
|
||||
|
||||
### 需要帮助?
|
||||
- 如果对问题描述有疑问,可以在下方留言
|
||||
- 开发过程中遇到任何问题,也欢迎随时提问
|
||||
|
||||
祝你编码愉快!"
|
||||
```
|
||||
|
||||
### Step 4:确认引导已生效
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
|
||||
```
|
||||
|
||||
检查 `comment_journals_count` 是否增加了。
|
||||
|
||||
---
|
||||
|
||||
## 完整命令速览
|
||||
|
||||
```bash
|
||||
# 1. 查找 good-first-issue
|
||||
gitlink-cli issue +list --state open
|
||||
|
||||
# 2. 查看详情
|
||||
gitlink-cli issue +view --number <id>
|
||||
|
||||
# 3. 添加引导评论
|
||||
gitlink-cli issue +comment --number <id> --body "<引导内容>"
|
||||
```
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
name: gitlink-project-health
|
||||
version: 1.0.0
|
||||
description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度,生成项目健康度分析报告。当用户需要了解项目整体运行状况时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli --help"
|
||||
---
|
||||
|
||||
# gitlink-project-health(项目健康度报告)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
|
||||
## 说明
|
||||
|
||||
本 Skill 组合多个 gitlink-cli 命令,从不同维度采集项目数据,生成结构化的项目健康度报告。
|
||||
|
||||
## 数据来源
|
||||
|
||||
| Shortcut | 采集的数据 |
|
||||
|----------|-----------|
|
||||
| `issue +list` | Issue 总数、打开/关闭数量、平均响应时间 |
|
||||
| `pr +list` | PR 总数、打开/合并/关闭数量、平均合并时间 |
|
||||
| `repo +info` | 仓库基本信息、fork 数、watch 数 |
|
||||
| `commit +list` | 近期提交频率、活跃开发者数 |
|
||||
| `label +list` | 标签使用情况 |
|
||||
| `milestone +list` | 里程碑完成进度 |
|
||||
|
||||
## 报告维度
|
||||
|
||||
### 1. Issue 健康度
|
||||
|
||||
```bash
|
||||
# 所有 Issue 统计
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
|
||||
|
||||
# 已关闭的 Issue(用于统计平均解决时间)
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state closed --limit 30
|
||||
|
||||
# 待处理的 Issue
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state open
|
||||
```
|
||||
|
||||
### 2. PR 健康度
|
||||
|
||||
```bash
|
||||
# 所有 PR 统计
|
||||
gitlink-cli pr +list --owner myuser --repo myrepo --state all --format json
|
||||
|
||||
# 待合并的 PR
|
||||
gitlink-cli pr +list --owner myuser --repo myrepo --state open
|
||||
|
||||
# 已合并的 PR(统计平均合并时间)
|
||||
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 30
|
||||
```
|
||||
|
||||
### 3. 仓库基本信息
|
||||
|
||||
```bash
|
||||
# 仓库概况
|
||||
gitlink-cli repo +info --owner myuser --repo myrepo --format json
|
||||
```
|
||||
|
||||
### 4. 里程碑进度
|
||||
|
||||
```bash
|
||||
# 查看里程碑
|
||||
gitlink-cli milestone +list --owner myuser --repo myrepo
|
||||
```
|
||||
|
||||
### 5. 提交活跃度
|
||||
|
||||
```bash
|
||||
# 最近提交
|
||||
gitlink-cli commit +list --owner myuser --repo myrepo --limit 20
|
||||
```
|
||||
|
||||
## 报告模板
|
||||
|
||||
收集完数据后,按以下模板生成报告:
|
||||
|
||||
```markdown
|
||||
# 项目健康度报告
|
||||
|
||||
## 基本信息
|
||||
- 项目:{repo_name}
|
||||
- 所有者:{owner}
|
||||
- Stars:{stars} | Forks:{forks}
|
||||
|
||||
## Issue 状况
|
||||
- 总 Issue 数:{total_issues}
|
||||
- 打开:{open_issues}(占比 {open_percent}%)
|
||||
- 已关闭:{closed_issues}(占比 {closed_percent}%)
|
||||
|
||||
## PR 状况
|
||||
- 总 PR 数:{total_prs}
|
||||
- 打开:{open_prs}
|
||||
- 已合并:{merged_prs}
|
||||
|
||||
## 活跃度
|
||||
- 近期提交:{recent_commits} 次
|
||||
- 活跃开发者:{active_devs} 人
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 完整报告生成流程
|
||||
|
||||
# 步骤1:获取仓库信息
|
||||
gitlink-cli repo +info --owner myuser --repo myrepo --format json
|
||||
|
||||
# 步骤2:获取 Issue 统计
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state open
|
||||
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
|
||||
|
||||
# 步骤3:获取 PR 统计
|
||||
gitlink-cli pr +list --owner myuser --repo myrepo --state open
|
||||
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 20
|
||||
|
||||
# 步骤4:获取提交活跃度
|
||||
gitlink-cli commit +list --owner myuser --repo myrepo --limit 30
|
||||
|
||||
# 步骤5:获取里程碑进度
|
||||
gitlink-cli milestone +list --owner myuser --repo myrepo
|
||||
|
||||
# 步骤6:汇总数据,生成报告
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 使用 `--format json` 获取结构化数据,便于 Agent 解析。
|
||||
- 指定 `--limit` 控制数据量,避免输出过大。
|
||||
- 不同项目的数据量级不同,可根据实际情况调整采样数量。
|
||||
- 报告中的"健康状况"建议使用颜色标识(绿色=健康,黄色=一般,红色=需关注)。
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# 项目健康度报告生成工作流示例
|
||||
|
||||
**场景**:项目维护者需要生成一份项目健康度周报,了解 Issue 处理情况、PR 合并效率和整体活跃度。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `gitlink-cli` 已安装并登录
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### Step 1:获取项目基本信息
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner z2_cc --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
### Step 2:统计 Issue 数据
|
||||
|
||||
```bash
|
||||
# 所有 Issue
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
|
||||
# 打开的 Issue
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open
|
||||
|
||||
# 已关闭的 Issue(最近 30 条)
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state closed --limit 30
|
||||
```
|
||||
|
||||
### Step 3:统计 PR 数据
|
||||
|
||||
```bash
|
||||
# 所有 PR
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
|
||||
# 待合并的 PR
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state open
|
||||
|
||||
# 已合并的 PR
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state merged --limit 30
|
||||
```
|
||||
|
||||
### Step 4:查看近期提交活跃度
|
||||
|
||||
```bash
|
||||
gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 20
|
||||
```
|
||||
|
||||
### Step 5:查看里程碑进度
|
||||
|
||||
```bash
|
||||
gitlink-cli milestone +list --owner z2_cc --repo gitlink-cli
|
||||
```
|
||||
|
||||
### Step 6:生成报告
|
||||
|
||||
汇总以上数据,生成结构化报告:
|
||||
|
||||
```markdown
|
||||
## 项目健康度周报
|
||||
|
||||
### Issue 状况
|
||||
- 总 Issue 数:12(打开 3 / 关闭 9)
|
||||
- 本周新增:2
|
||||
- 本周解决:3
|
||||
|
||||
### PR 状况
|
||||
- 总 PR 数:5(打开 1 / 已合并 4)
|
||||
- 本周新增:1
|
||||
- 本周合并:2
|
||||
|
||||
### 活跃度
|
||||
- 近 20 次提交涉及:3 位贡献者
|
||||
- 最近提交时间:2 小时前
|
||||
|
||||
### 里程碑
|
||||
- 进行中:v2.0(完成 60%)
|
||||
|
||||
🟢 总体评价:项目健康
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整命令速览
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info
|
||||
gitlink-cli issue +list --state all --format json
|
||||
gitlink-cli pr +list --state all --format json
|
||||
gitlink-cli commit +list --limit 20
|
||||
gitlink-cli milestone +list
|
||||
```
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
name: gitlink-snippet
|
||||
version: 1.0.0
|
||||
description: "代码片段管理:在仓库的 snippets/ 目录下管理代码片段,支持列出、查看、创建、删除。当用户需要管理或共享代码片段时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli snippet --help"
|
||||
---
|
||||
|
||||
# gitlink-snippet(代码片段管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 说明
|
||||
|
||||
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个独立的文件。
|
||||
读操作(list/view)通过 Git 克隆实现,无需认证;写操作(create/delete)通过文件 REST API 实现,需要 Token。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `snippet +list` | 列出代码片段 | 否(公开项目) |
|
||||
| `snippet +view` | 查看代码片段内容 | 否(公开项目) |
|
||||
| `snippet +create` | 创建代码片段 | 是 |
|
||||
| `snippet +delete` | 删除代码片段 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 列出代码片段
|
||||
gitlink-cli snippet +list --owner myuser --repo myrepo
|
||||
|
||||
# 查看代码片段
|
||||
gitlink-cli snippet +view --owner myuser --repo myrepo --name "hello.py"
|
||||
|
||||
# 创建代码片段(需要认证)
|
||||
gitlink-cli snippet +create --owner myuser --repo myrepo --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
|
||||
|
||||
# 删除代码片段(确认后再执行)
|
||||
gitlink-cli snippet +delete --owner myuser --repo myrepo --name "old.py"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 片段名带扩展名(如 `.py`、`.go`)会按原样存储;不带扩展名会自动追加 `.md`。
|
||||
- `--language` 参数用于代码块语法高亮,不影响文件名。
|
||||
- 执行写入/删除前务必确认用户意图。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [gitlink-repo](../gitlink-repo/SKILL.md) — 仓库管理
|
||||
|
|
@ -21,6 +21,8 @@ metadata:
|
|||
| `webhook +delete` | 删除 webhook |
|
||||
| `webhook +test` | 触发 webhook 测试投递 |
|
||||
| `webhook +tasks` | 查看 webhook 投递任务历史 |
|
||||
| `webhook +failed` | 查看投递失败的记录 |
|
||||
| `webhook +task-view` | 查看单次投递的详细内容 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -30,4 +32,6 @@ gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
|||
--url https://example.com/hook --events push,create
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
gitlink-cli webhook +failed --owner Gitlink --repo forgeplus --id 68
|
||||
gitlink-cli webhook +task-view --owner Gitlink --repo forgeplus --id 68 --task-id 4775684
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
name: gitlink-wiki
|
||||
version: 1.0.0
|
||||
description: "Wiki 管理:基于 Git 仓库操作,支持 Wiki 页面的列出、查看、创建、更新、删除。当用户需要管理 GitLink 项目 Wiki 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli wiki --help"
|
||||
---
|
||||
|
||||
# gitlink-wiki(Wiki 管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 说明
|
||||
|
||||
GitLink 的 Wiki 是一个独立的 Git 仓库(`{owner}/{repo}.wiki.git`),每个 Wiki 页面是一个 `.md` 文件。
|
||||
读操作(list/view)无需认证,写操作(create/update/delete)需要 Personal Access Token。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `wiki +list` | 列出 Wiki 页面 | 否(公开项目) |
|
||||
| `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) |
|
||||
| `wiki +create` | 创建 Wiki 页面 | 是 |
|
||||
| `wiki +update` | 更新 Wiki 页面 | 是 |
|
||||
| `wiki +delete` | 删除 Wiki 页面 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 列出 Wiki 页面
|
||||
gitlink-cli wiki +list --owner myuser --repo myrepo
|
||||
|
||||
# 查看 Wiki 页面内容
|
||||
gitlink-cli wiki +view --owner myuser --repo myrepo --page-name "Home"
|
||||
|
||||
# 创建 Wiki 页面(需要认证)
|
||||
gitlink-cli wiki +create --owner myuser --repo myrepo --page-name "guide" --title "使用指南" --content "这是使用指南" --message "创建新页面"
|
||||
|
||||
# 更新 Wiki 页面
|
||||
gitlink-cli wiki +update --owner myuser --repo myrepo --page-name "guide" --title "更新标题"
|
||||
|
||||
# 删除 Wiki 页面(确认后再执行)
|
||||
gitlink-cli wiki +delete --owner myuser --repo myrepo --page-name "guide"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 更改页面名或删除页面后,自动生成的 _Sidebar.md 不会立即更新,等待 GitLink 网页端重建。
|
||||
- 中文页面名会进行 URL 编码(如 `Wiki测试` → `Wiki%E6%B5%8B%E8%AF%95.md`)。
|
||||
- `--content` 和 `--file` 不能同时使用。
|
||||
- 如果不在仓库目录内运行,需要 `--owner` 和 `--repo` 参数。
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
```python
|
||||
print(Hello World!)
|
||||
```
|
||||
Loading…
Reference in New Issue