Compare commits
37 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
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
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.22'
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -57,6 +57,10 @@ jobs:
|
||||||
rm -rf "$BUILD_DIR"
|
rm -rf "$BUILD_DIR"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Generate SHA256 checksums
|
||||||
|
cd dist
|
||||||
|
sha256sum *.tar.gz *.zip > checksums-sha256.txt
|
||||||
|
cd ..
|
||||||
ls -lh dist
|
ls -lh dist
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|
@ -65,8 +69,44 @@ jobs:
|
||||||
files: |
|
files: |
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
|
dist/checksums-sha256.txt
|
||||||
generate_release_notes: true
|
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
|
- name: Build npm package
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF#refs/tags/v}
|
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")
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
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:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||||
|
|
@ -13,6 +15,43 @@ install:
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
rm -rf dist/
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go 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(newLoginCmd())
|
||||||
cmd.AddCommand(newLogoutCmd())
|
cmd.AddCommand(newLogoutCmd())
|
||||||
cmd.AddCommand(newStatusCmd())
|
cmd.AddCommand(newStatusCmd())
|
||||||
|
cmd.AddCommand(newRefreshCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginCmd() *cobra.Command {
|
func newLoginCmd() *cobra.Command {
|
||||||
var tokenMode bool
|
var tokenMode bool
|
||||||
|
var oauthMode bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "login",
|
Use: "login",
|
||||||
|
|
@ -36,10 +38,14 @@ func newLoginCmd() *cobra.Command {
|
||||||
if tokenMode {
|
if tokenMode {
|
||||||
return loginWithToken()
|
return loginWithToken()
|
||||||
}
|
}
|
||||||
|
if oauthMode {
|
||||||
|
return loginWithOAuth()
|
||||||
|
}
|
||||||
return loginWithPassword()
|
return loginWithPassword()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
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
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,6 @@ var (
|
||||||
Repo string
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
ColorMode string
|
||||||
|
AutoConfirm bool
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,17 @@ import (
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
var colorMode string
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "gitlink-cli",
|
Use: "gitlink-cli",
|
||||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
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.`,
|
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,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
cmdutil.ColorMode = colorMode
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
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().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(authCmd.NewAuthCmd())
|
||||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Webhook Shortcut
|
# Webhook Shortcut
|
||||||
|
|
||||||
新增 `webhook` Shortcut 组,支持:
|
新增 `webhook` Shortcut 组,支持
|
||||||
|
|
||||||
- `webhook +list`
|
- `webhook +list`
|
||||||
- `webhook +create`
|
- `webhook +create`
|
||||||
|
|
|
||||||
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
|
module github.com/gitlink-org/gitlink-cli
|
||||||
|
|
||||||
go 1.26.1
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|
|
||||||
|
|
@ -150,3 +150,157 @@ func GetCurrentUser() (map[string]interface{}, error) {
|
||||||
|
|
||||||
return result, nil
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
|
|
@ -41,8 +42,30 @@ func DeleteToken() error {
|
||||||
// File-based fallback
|
// File-based fallback
|
||||||
|
|
||||||
func credentialPath() string {
|
func credentialPath() string {
|
||||||
|
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()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
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 {
|
func storeTokenFile(token string) error {
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,14 @@ type APIError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Code interface{}
|
Code interface{}
|
||||||
Message string
|
Message string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *APIError) Error() 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)
|
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,
|
StatusCode: resp.StatusCode,
|
||||||
Code: resp.StatusCode,
|
Code: resp.StatusCode,
|
||||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
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),
|
StatusCode: int(statusCode),
|
||||||
Code: int(statusCode),
|
Code: int(statusCode),
|
||||||
Message: msg,
|
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 {
|
func suggestFix(code int) string {
|
||||||
switch code {
|
switch code {
|
||||||
case 401:
|
case 401:
|
||||||
return "请先运行 gitlink-cli auth login 登录"
|
return "Run 'gitlink auth login' to authenticate"
|
||||||
case 403:
|
case 403:
|
||||||
return "权限不足,请确认账户权限或联系项目管理员"
|
return "Permission denied. Check your account permissions or contact the project admin"
|
||||||
case 404:
|
case 404:
|
||||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
return "Resource not found. Verify --owner, --repo, and the resource ID"
|
||||||
case 422:
|
case 422:
|
||||||
return "参数校验失败,请检查请求参数"
|
return "Validation failed. Check the request parameters"
|
||||||
|
case 500:
|
||||||
|
return "Server error. Try again later or contact the platform admin"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -26,13 +27,33 @@ 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 {
|
func ConfigDir() string {
|
||||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||||
return 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()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".config", "gitlink-cli")
|
return filepath.Join(home, ".config", "gitlink-cli")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ConfigPath() string {
|
func ConfigPath() string {
|
||||||
return filepath.Join(ConfigDir(), "config.yaml")
|
return filepath.Join(ConfigDir(), "config.yaml")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -178,3 +179,53 @@ func formatValue(v interface{}) string {
|
||||||
return fmt.Sprintf("%v", v)
|
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 fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const https = require("https");
|
||||||
|
const http = require("http");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
const BINARY_NAME = "gitlink-cli";
|
const BINARY_NAME = "gitlink-cli";
|
||||||
|
const PACKAGE = require("../package.json");
|
||||||
|
const CURRENT_VERSION = PACKAGE.version;
|
||||||
|
|
||||||
function getBinaryName(platform = process.platform) {
|
function getBinaryName(platform = process.platform) {
|
||||||
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||||
|
|
@ -36,6 +40,36 @@ function formatMissingBinaryError(
|
||||||
].join("\n");
|
].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 = {}) {
|
function run(args = process.argv.slice(2), options = {}) {
|
||||||
const platform = options.platform || process.platform;
|
const platform = options.platform || process.platform;
|
||||||
const arch = options.arch || process.arch;
|
const arch = options.arch || process.arch;
|
||||||
|
|
@ -49,10 +83,16 @@ function run(args = process.argv.slice(2), options = {}) {
|
||||||
return exit(1);
|
return exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Binary existence check before any other logic
|
||||||
if (!fs.existsSync(binaryPath)) {
|
if (!fs.existsSync(binaryPath)) {
|
||||||
return failMissingBinary();
|
return failMissingBinary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run optional update check in background (fire-and-forget)
|
||||||
|
if (require.main === module) {
|
||||||
|
checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFile(binaryPath, args, { stdio: "inherit" });
|
execFile(binaryPath, args, { stdio: "inherit" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -76,4 +116,5 @@ module.exports = {
|
||||||
getBinaryPath,
|
getBinaryPath,
|
||||||
formatMissingBinaryError,
|
formatMissingBinaryError,
|
||||||
run,
|
run,
|
||||||
|
checkForUpdate,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const crypto = require("crypto");
|
||||||
const https = require("https");
|
const https = require("https");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
|
|
@ -13,13 +14,14 @@ const PACKAGE = require("../package.json");
|
||||||
const VERSION = PACKAGE.version;
|
const VERSION = PACKAGE.version;
|
||||||
const BINARY_NAME = "gitlink-cli";
|
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 RELEASE_BASE = "https://www.gitlink.org.cn";
|
||||||
const REPO_OWNER = "Gitlink";
|
const REPO_OWNER = "Gitlink";
|
||||||
const REPO_NAME = "gitlink-cli";
|
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()) {
|
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||||
const platformMap = {
|
const platformMap = {
|
||||||
darwin: "darwin",
|
darwin: "darwin",
|
||||||
|
|
@ -54,15 +56,60 @@ function getArchiveName(platform, arch) {
|
||||||
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
|
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function fetch(url, options = {}) {
|
function fetch(url, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const maxRedirects = options.maxRedirects || 5;
|
const maxRedirects = options.maxRedirects || 5;
|
||||||
let redirectCount = 0;
|
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) {
|
function doRequest(currentUrl) {
|
||||||
|
const proxyUrl = getProxy(currentUrl);
|
||||||
const mod = currentUrl.startsWith("https") ? https : http;
|
const mod = currentUrl.startsWith("https") ? https : http;
|
||||||
const req = mod.get(currentUrl, (res) => {
|
const parsedUrl = new URL(currentUrl);
|
||||||
// Follow redirects
|
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 (
|
if (
|
||||||
(res.statusCode === 301 ||
|
(res.statusCode === 301 ||
|
||||||
res.statusCode === 302 ||
|
res.statusCode === 302 ||
|
||||||
|
|
@ -103,12 +150,36 @@ function fetch(url, options = {}) {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
res.on("data", (chunk) => chunks.push(chunk));
|
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
|
||||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
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.on("error", reject);
|
||||||
req.setTimeout(60000, () => {
|
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error("Request timed out"));
|
reject(new Error("Request timed out"));
|
||||||
});
|
});
|
||||||
|
|
@ -122,14 +193,12 @@ async function findReleaseAsset(platform, arch) {
|
||||||
const archiveName = getArchiveName(platform, arch);
|
const archiveName = getArchiveName(platform, arch);
|
||||||
const tagName = `v${VERSION}`;
|
const tagName = `v${VERSION}`;
|
||||||
|
|
||||||
// Try fetching release info from GitLink API
|
|
||||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||||
console.log(`Fetching release info from ${apiUrl}`);
|
console.log(`Fetching release info from ${apiUrl}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const releases = await fetch(apiUrl, { json: true });
|
const releases = await fetch(apiUrl, { json: true });
|
||||||
|
|
||||||
// Find the release matching our version
|
|
||||||
let release = null;
|
let release = null;
|
||||||
|
|
||||||
if (Array.isArray(releases)) {
|
if (Array.isArray(releases)) {
|
||||||
|
|
@ -137,7 +206,6 @@ async function findReleaseAsset(platform, arch) {
|
||||||
(r) => r.tag_name === tagName || r.tag_name === VERSION
|
(r) => r.tag_name === tagName || r.tag_name === VERSION
|
||||||
);
|
);
|
||||||
if (!release && releases.length > 0) {
|
if (!release && releases.length > 0) {
|
||||||
// Fall back to latest release
|
|
||||||
release = releases[0];
|
release = releases[0];
|
||||||
}
|
}
|
||||||
} else if (releases && releases.releases) {
|
} else if (releases && releases.releases) {
|
||||||
|
|
@ -151,11 +219,9 @@ async function findReleaseAsset(platform, arch) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (release && release.attachments) {
|
if (release && release.attachments) {
|
||||||
// Try exact match first
|
|
||||||
let asset = release.attachments.find(
|
let asset = release.attachments.find(
|
||||||
(a) => a.title === archiveName || a.filename === archiveName
|
(a) => a.title === archiveName || a.filename === archiveName
|
||||||
);
|
);
|
||||||
// If no exact match (version mismatch on fallback release), match by platform/arch pattern
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
||||||
const pattern = `_${platform}_${arch}${ext}`;
|
const pattern = `_${platform}_${arch}${ext}`;
|
||||||
|
|
@ -164,9 +230,8 @@ async function findReleaseAsset(platform, arch) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (asset) {
|
if (asset) {
|
||||||
// Return the download URL for this attachment
|
let downloadUrl =
|
||||||
let downloadUrl = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||||
// Ensure absolute URL
|
|
||||||
if (downloadUrl.startsWith("/")) {
|
if (downloadUrl.startsWith("/")) {
|
||||||
downloadUrl = RELEASE_BASE + downloadUrl;
|
downloadUrl = RELEASE_BASE + downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
@ -177,11 +242,63 @@ async function findReleaseAsset(platform, arch) {
|
||||||
console.log(`Warning: Could not fetch release info: ${e.message}`);
|
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}`;
|
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}...`);
|
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
|
||||||
|
|
||||||
const data = await fetch(url);
|
const data = await fetch(url);
|
||||||
|
|
@ -192,15 +309,10 @@ async function downloadAndExtract(url, destDir, platform) {
|
||||||
fs.writeFileSync(archivePath, data);
|
fs.writeFileSync(archivePath, data);
|
||||||
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
|
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
|
||||||
// Extract
|
// Verify checksum if provided
|
||||||
if (isWindows) {
|
verifyChecksum(archivePath, expectedChecksum);
|
||||||
execSync(
|
|
||||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
|
extractArchive(archivePath, destDir, platform);
|
||||||
{ stdio: "pipe" }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
|
||||||
}
|
|
||||||
fs.unlinkSync(archivePath);
|
fs.unlinkSync(archivePath);
|
||||||
|
|
||||||
// Find the binary in extracted files
|
// Find the binary in extracted files
|
||||||
|
|
@ -208,7 +320,6 @@ async function downloadAndExtract(url, destDir, platform) {
|
||||||
const binaryPath = path.join(destDir, binaryName);
|
const binaryPath = path.join(destDir, binaryName);
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
if (!fs.existsSync(binaryPath)) {
|
||||||
// It might be in a subdirectory
|
|
||||||
const files = fs.readdirSync(destDir);
|
const files = fs.readdirSync(destDir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const subPath = path.join(destDir, file, binaryName);
|
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`);
|
throw new Error(`Binary "${binaryName}" not found after extraction`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make executable (not needed on Windows)
|
|
||||||
if (!isWindows) {
|
if (!isWindows) {
|
||||||
fs.chmodSync(binaryPath, 0o755);
|
fs.chmodSync(binaryPath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
@ -251,22 +361,58 @@ async function main() {
|
||||||
// If binary already exists, check version matches
|
// If binary already exists, check version matches
|
||||||
if (fs.existsSync(binaryPath)) {
|
if (fs.existsSync(binaryPath)) {
|
||||||
try {
|
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)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
|
console.log(
|
||||||
} catch (e) {
|
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
|
||||||
console.log(`${BINARY_NAME} binary exists but is not compatible, re-downloading...`);
|
);
|
||||||
|
} catch {
|
||||||
|
console.log(
|
||||||
|
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
fs.unlinkSync(binaryPath);
|
fs.unlinkSync(binaryPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
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) {
|
} 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}`);
|
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
|
||||||
|
if (classifiedMsg) {
|
||||||
|
console.error(`\n诊断信息: ${classifiedMsg}`);
|
||||||
|
}
|
||||||
if (platformInfo) {
|
if (platformInfo) {
|
||||||
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
|
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
|
||||||
}
|
}
|
||||||
|
|
@ -292,4 +438,6 @@ module.exports = {
|
||||||
getBinaryName,
|
getBinaryName,
|
||||||
getArchiveName,
|
getArchiveName,
|
||||||
findReleaseAsset,
|
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,294 @@
|
||||||
|
package board
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v1RepoPath returns the v1 API path prefix.
|
||||||
|
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||||
|
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shortcuts returns project board management shortcuts.
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "Show project board (issues grouped by status)",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||||
|
{Name: "milestone", Short: "m", Usage: "Milestone ID to filter by"},
|
||||||
|
},
|
||||||
|
Run: runView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "columns",
|
||||||
|
Description: "List available board columns (issue statuses)",
|
||||||
|
Run: runColumns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "move",
|
||||||
|
Description: "Move an issue to a different status column",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||||
|
{Name: "state", Short: "s", Usage: "Target state: open, in_progress, resolved, closed, or numeric status_id", Required: true},
|
||||||
|
},
|
||||||
|
Run: runMove,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default known status mapping (fallback if API call fails)
|
||||||
|
var knownStatuses = map[int]string{
|
||||||
|
1: "New",
|
||||||
|
2: "In Progress",
|
||||||
|
3: "Resolved",
|
||||||
|
4: "Reopened",
|
||||||
|
5: "Closed",
|
||||||
|
6: "Rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusNameToID maps common names to status IDs
|
||||||
|
// Supports both Chinese and English names
|
||||||
|
var statusNameToID = map[string]int{
|
||||||
|
"open": 1,
|
||||||
|
"new": 1,
|
||||||
|
"新增": 1,
|
||||||
|
"in_progress": 2,
|
||||||
|
"inprogress": 2,
|
||||||
|
"wip": 2,
|
||||||
|
"正在解决": 2,
|
||||||
|
"resolved": 3,
|
||||||
|
"已解决": 3,
|
||||||
|
"reopened": 4,
|
||||||
|
"closed": 5,
|
||||||
|
"close": 5,
|
||||||
|
"关闭": 5,
|
||||||
|
"rejected": 6,
|
||||||
|
"拒绝": 6,
|
||||||
|
"feedback": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Commands ---
|
||||||
|
|
||||||
|
func runView(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("limit", "100")
|
||||||
|
if s := ctx.Arg("state"); s != "" {
|
||||||
|
q.Set("state", s)
|
||||||
|
}
|
||||||
|
if m := ctx.Arg("milestone"); m != "" {
|
||||||
|
q.Set("fixed_version_id", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse issues and group by status
|
||||||
|
raw, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuesRaw, ok := raw["issues"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
// No issues, return empty board
|
||||||
|
return ctx.OutputData(map[string]interface{}{
|
||||||
|
"columns": []map[string]interface{}{},
|
||||||
|
"total": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group issues by status_id
|
||||||
|
columns := make(map[int][]map[string]interface{})
|
||||||
|
statusOrder := make([]int, 0)
|
||||||
|
|
||||||
|
for _, rawIssue := range issuesRaw {
|
||||||
|
issue, ok := rawIssue.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract number
|
||||||
|
number := 0
|
||||||
|
if n, ok := issue["project_issues_index"].(float64); ok {
|
||||||
|
number = int(n)
|
||||||
|
} else if n, ok := issue["number"]; ok {
|
||||||
|
if v, ok := n.(float64); ok {
|
||||||
|
number = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract status_id
|
||||||
|
statusID := 0
|
||||||
|
if s, ok := issue["status_id"]; ok {
|
||||||
|
if v, ok := s.(float64); ok {
|
||||||
|
statusID = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract subject
|
||||||
|
subject, _ := issue["subject"].(string)
|
||||||
|
|
||||||
|
// Extract assignee
|
||||||
|
assignee := ""
|
||||||
|
if assigned, ok := issue["assigned_to"]; ok {
|
||||||
|
if a, ok := assigned.(map[string]interface{}); ok {
|
||||||
|
if login, ok := a["login"].(string); ok {
|
||||||
|
assignee = login
|
||||||
|
} else if name, ok := a["name"].(string); ok {
|
||||||
|
assignee = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"number": number,
|
||||||
|
"subject": subject,
|
||||||
|
"assignee": assignee,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := columns[statusID]; !exists {
|
||||||
|
columns[statusID] = make([]map[string]interface{}, 0)
|
||||||
|
statusOrder = append(statusOrder, statusID)
|
||||||
|
}
|
||||||
|
columns[statusID] = append(columns[statusID], item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort status IDs
|
||||||
|
sort.Ints(statusOrder)
|
||||||
|
|
||||||
|
// Build output
|
||||||
|
boardColumns := make([]map[string]interface{}, 0)
|
||||||
|
for _, sid := range statusOrder {
|
||||||
|
name, ok := knownStatuses[sid]
|
||||||
|
if !ok {
|
||||||
|
name = fmt.Sprintf("Status %d", sid)
|
||||||
|
}
|
||||||
|
boardColumns = append(boardColumns, map[string]interface{}{
|
||||||
|
"id": sid,
|
||||||
|
"name": name,
|
||||||
|
"issues": columns[sid],
|
||||||
|
"count": len(columns[sid]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for _, col := range boardColumns {
|
||||||
|
total += col["count"].(int)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.OutputData(map[string]interface{}{
|
||||||
|
"columns": boardColumns,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runColumns(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fetching from API first
|
||||||
|
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/issue_statues", nil)
|
||||||
|
if err == nil {
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to known statuses
|
||||||
|
type statusInfo struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
statuses := make([]statusInfo, 0)
|
||||||
|
for id, name := range knownStatuses {
|
||||||
|
statuses = append(statuses, statusInfo{ID: id, Name: name})
|
||||||
|
}
|
||||||
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
|
return statuses[i].ID < statuses[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
return ctx.OutputData(map[string]interface{}{
|
||||||
|
"statuses": statuses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMove(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
number, err := ctx.RequireArg("number")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := ctx.Arg("state")
|
||||||
|
if state == "" {
|
||||||
|
return fmt.Errorf("required flag --state is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve status_id from state string or numeric
|
||||||
|
var statusID int
|
||||||
|
if id, err := strconv.Atoi(state); err == nil {
|
||||||
|
statusID = id
|
||||||
|
} else if id, ok := statusNameToID[strings.ToLower(strings.TrimSpace(state))]; ok {
|
||||||
|
statusID = id
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("invalid --state %q: use open, in_progress, resolved, closed, or a numeric status_id", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current issue to preserve subject and description
|
||||||
|
current, err := fetchIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch issue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the issue status
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"status_id": statusID,
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
type existingIssue struct {
|
||||||
|
Subject string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||||
|
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, ok := getEnv.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to parse issue data")
|
||||||
|
}
|
||||||
|
subject, _ := data["subject"].(string)
|
||||||
|
description, _ := data["description"].(string)
|
||||||
|
return &existingIssue{
|
||||||
|
Subject: subject,
|
||||||
|
Description: description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
package board
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Test helpers ---
|
||||||
|
|
||||||
|
func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findBoardShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "test-owner",
|
||||||
|
Repo: "test-repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
if ctx.Args == nil {
|
||||||
|
ctx.Args = map[string]string{}
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBoardShortcut(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 newBoardTestServer(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 assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if r.Method != method || r.URL.Path != path {
|
||||||
|
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertQueryParam(t *testing.T, r *http.Request, key, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
actual := r.URL.Query().Get(key)
|
||||||
|
if actual != expected {
|
||||||
|
t.Fatalf("query param %q = %q, want %q", key, actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
func TestBoardView(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||||
|
assertQueryParam(t, r, "limit", "100")
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 3,
|
||||||
|
"issues": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(1),
|
||||||
|
"subject": "Fix login bug",
|
||||||
|
"status_id": float64(1),
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(2),
|
||||||
|
"subject": "Add dark mode",
|
||||||
|
"status_id": float64(2),
|
||||||
|
"assigned_to": map[string]interface{}{
|
||||||
|
"login": "dev1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(3),
|
||||||
|
"subject": "Update docs",
|
||||||
|
"status_id": float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardViewWithStateFilter(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||||
|
assertQueryParam(t, r, "state", "closed")
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 1,
|
||||||
|
"issues": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(3),
|
||||||
|
"subject": "Closed issue",
|
||||||
|
"status_id": float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", map[string]string{
|
||||||
|
"state": "closed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board view with state filter failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardViewWithMilestoneFilter(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||||
|
assertQueryParam(t, r, "fixed_version_id", "42")
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 0,
|
||||||
|
"issues": []interface{}{},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", map[string]string{
|
||||||
|
"milestone": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board view with milestone filter failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardViewEmpty(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 0,
|
||||||
|
"issues": []interface{}{},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board view for empty repo failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardColumns(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issue_statues.json")
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"statues": []interface{}{
|
||||||
|
map[string]interface{}{"id": float64(1), "name": "新增"},
|
||||||
|
map[string]interface{}{"id": float64(2), "name": "正在解决"},
|
||||||
|
map[string]interface{}{"id": float64(3), "name": "已解决"},
|
||||||
|
map[string]interface{}{"id": float64(5), "name": "关闭"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "columns", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board columns failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardColumnsFallback(t *testing.T) {
|
||||||
|
// Server returns 404, which should trigger the fallback to known statuses
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "not found"})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "columns", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board columns fallback failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMove(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
// First call: fetch current issue
|
||||||
|
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues/5.json")
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"subject": "Test issue",
|
||||||
|
"description": "Test description",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Second call: update issue status
|
||||||
|
assertRequest(t, r, "PATCH", "/v1/test-owner/test-repo/issues/5.json")
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status_id"] != float64(2) {
|
||||||
|
t.Fatalf("status_id = %v, want 2", payload["status_id"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": 5, "status_id": 2})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "5",
|
||||||
|
"state": "in_progress",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board move failed: %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveWithChineseState(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"subject": "Test",
|
||||||
|
"description": "Test",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["status_id"] != float64(5) {
|
||||||
|
t.Fatalf("status_id = %v, want 5", payload["status_id"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "1",
|
||||||
|
"state": "关闭",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board move with Chinese state failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveRejectsMissingNumber(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"state": "closed",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when --number is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveRejectsMissingState(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when --state is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveRejectsInvalidState(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "1",
|
||||||
|
"state": "invalid_state_name",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveWithNumericState(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"subject": "Test",
|
||||||
|
"description": "Test",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["status_id"] != float64(3) {
|
||||||
|
t.Fatalf("status_id = %v, want 3", payload["status_id"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "1",
|
||||||
|
"state": "3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board move with numeric state failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardViewHandlesAPIError(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "not found"})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when API returns 404")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardMoveHandlesAPIFetchError(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "issue not found"})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||||
|
"number": "999",
|
||||||
|
"state": "closed",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when issue does not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardViewGroupsIssuesCorrectly(t *testing.T) {
|
||||||
|
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"issues": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(1),
|
||||||
|
"subject": "Bug A",
|
||||||
|
"status_id": float64(1),
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(2),
|
||||||
|
"subject": "Bug B",
|
||||||
|
"status_id": float64(1),
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(3),
|
||||||
|
"subject": "Task A",
|
||||||
|
"status_id": float64(2),
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"project_issues_index": float64(4),
|
||||||
|
"subject": "Done A",
|
||||||
|
"status_id": float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBoardShortcut(t, server, "view", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("board view grouping failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
|
newBatchDeleteShortcut(),
|
||||||
|
newBatchProtectShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List branches",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
|
@ -33,9 +44,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a branch",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -44,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
name, _ := ctx.RequireArg("name")
|
name, _ := ctx.RequireArg("name")
|
||||||
from := ctx.Arg("from")
|
from := ctx.Arg("from")
|
||||||
if from == "" {
|
if from == "" {
|
||||||
from = "master"
|
from = common.GetDefaultBranch(ctx)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"new_branch_name": name,
|
"new_branch_name": name,
|
||||||
|
|
@ -60,6 +80,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a branch",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -68,6 +93,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
name, _ := ctx.RequireArg("name")
|
name, _ := ctx.RequireArg("name")
|
||||||
|
if err := common.ConfirmAction(
|
||||||
|
fmt.Sprintf("delete branch %s", name),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"branch_name": name,
|
"branch_name": name,
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +111,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "protect",
|
Name: "protect",
|
||||||
Description: "Set branch protection",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -102,6 +137,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "unprotect",
|
Name: "unprotect",
|
||||||
Description: "Remove branch protection",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -117,5 +157,61 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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",
|
Name: "builds",
|
||||||
Description: "List CI 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{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -23,6 +53,21 @@ func Shortcuts() []*common.Shortcut {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
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)
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -33,6 +78,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "logs",
|
Name: "logs",
|
||||||
Description: "View build 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{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||||
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
||||||
|
|
@ -61,6 +115,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "restart",
|
Name: "restart",
|
||||||
Description: "Restart a build",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -79,6 +138,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "stop",
|
Name: "stop",
|
||||||
Description: "Stop a build",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -87,6 +151,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
build, _ := ctx.RequireArg("build")
|
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)
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -94,5 +163,208 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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)
|
return s.Run(ctx)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if s.Long != "" {
|
||||||
|
cmd.Long = s.Long
|
||||||
|
}
|
||||||
|
if s.Example != "" {
|
||||||
|
cmd.Example = s.Example
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range s.Flags {
|
for _, f := range s.Flags {
|
||||||
if f.Bool {
|
if f.Bool {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import (
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
|
Long string
|
||||||
|
Example string
|
||||||
Flags []Flag
|
Flags []Flag
|
||||||
Run func(ctx *RuntimeContext) error
|
Run func(ctx *RuntimeContext) error
|
||||||
}
|
}
|
||||||
|
|
@ -117,3 +119,25 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||||
}
|
}
|
||||||
return v, nil
|
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
|
package issue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const closedIssueStatusID = 5
|
const closedIssueStatusID = 5
|
||||||
|
|
||||||
type batchCloseResult struct {
|
// ---------------------------------------------------------------------------
|
||||||
Number string `json:"number" yaml:"number"`
|
// Shared: resolve database IDs from user-facing issue numbers
|
||||||
Action string `json:"action" yaml:"action"`
|
// ---------------------------------------------------------------------------
|
||||||
Status string `json:"status" yaml:"status"`
|
|
||||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchCloseSummary struct {
|
data, ok := env.Data.(map[string]interface{})
|
||||||
Repository string `json:"repository" yaml:"repository"`
|
if !ok {
|
||||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
return nil, fmt.Errorf("unexpected issues response format")
|
||||||
Total int `json:"total" yaml:"total"`
|
|
||||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
|
||||||
Failed int `json:"failed" yaml:"failed"`
|
|
||||||
Results []batchCloseResult `json:"results" yaml:"results"`
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// batch-close
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func newBatchCloseShortcut() *common.Shortcut {
|
func newBatchCloseShortcut() *common.Shortcut {
|
||||||
return &common.Shortcut{
|
return &common.Shortcut{
|
||||||
Name: "batch-close",
|
Name: "batch-close",
|
||||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
{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"},
|
{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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||||
}
|
}
|
||||||
|
|
||||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||||
summary := batchCloseSummary{
|
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
|
||||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
return closeIssue(ctx, number)
|
||||||
DryRun: dryRun,
|
})
|
||||||
Total: len(numbers),
|
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctx.OutputData(summary); err != nil {
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -108,105 +139,187 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if csvPath == "" {
|
if len(numbers) == 0 {
|
||||||
return numbers, nil
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
|
||||||
return mergeIssueNumbers(numbers, csvNumbers), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseIssueNumbers(value string) ([]string, error) {
|
body := map[string]interface{}{
|
||||||
if strings.TrimSpace(value) == "" {
|
"ids": dbIDs,
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
return normalizeIssueNumbers(strings.Split(value, ","))
|
if state != "" {
|
||||||
}
|
statusID, err := normalizeIssueStatus(state)
|
||||||
|
|
||||||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
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
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
reader := csv.NewReader(file)
|
if dryRun {
|
||||||
reader.TrimLeadingSpace = true
|
plan := map[string]interface{}{
|
||||||
records, err := reader.ReadAll()
|
"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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
return fmt.Errorf("batch update: %w", err)
|
||||||
}
|
}
|
||||||
if len(records) == 0 {
|
return ctx.Output(env)
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
numberColumn := -1
|
// ---------------------------------------------------------------------------
|
||||||
startRow := 0
|
// batch-destroy
|
||||||
for i, cell := range records[0] {
|
// ---------------------------------------------------------------------------
|
||||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
|
||||||
case "number", "issue_number", "project_issues_index":
|
func newBatchDestroyShortcut() *common.Shortcut {
|
||||||
numberColumn = i
|
return &common.Shortcut{
|
||||||
startRow = 1
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if numberColumn == -1 {
|
|
||||||
numberColumn = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
values := make([]string, 0, len(records)-startRow)
|
func runBatchDestroy(ctx *common.RuntimeContext) error {
|
||||||
for _, record := range records[startRow:] {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
if numberColumn >= len(record) {
|
return err
|
||||||
continue
|
|
||||||
}
|
|
||||||
values = append(values, record[numberColumn])
|
|
||||||
}
|
|
||||||
return normalizeIssueNumbers(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeIssueNumbers(values []string) ([]string, error) {
|
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
numbers := make([]string, 0, len(values))
|
if err != nil {
|
||||||
seen := map[string]bool{}
|
return err
|
||||||
for _, value := range values {
|
|
||||||
number := strings.TrimSpace(value)
|
|
||||||
if number == "" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
if len(numbers) == 0 {
|
||||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||||
}
|
|
||||||
if seen[number] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[number] = true
|
|
||||||
numbers = append(numbers, number)
|
|
||||||
}
|
|
||||||
return numbers, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeIssueNumbers(values ...[]string) []string {
|
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||||
merged := []string{}
|
|
||||||
seen := map[string]bool{}
|
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
|
||||||
for _, numbers := range values {
|
if err != nil {
|
||||||
for _, number := range numbers {
|
return err
|
||||||
if seen[number] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[number] = true
|
|
||||||
merged = append(merged, number)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBool(value string) bool {
|
if dryRun {
|
||||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
plan := map[string]interface{}{
|
||||||
return err == nil && parsed
|
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
"dry_run": true,
|
||||||
|
"issues": numbers,
|
||||||
|
"database_ids": dbIDs,
|
||||||
|
}
|
||||||
|
return ctx.OutputData(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseIssueNumbers(t *testing.T) {
|
func TestParseNumberList(t *testing.T) {
|
||||||
got, err := parseIssueNumbers("1, 2,2, 3")
|
got, err := common.ParseNumberList("1, 2,2, 3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parseIssueNumbers returned error: %v", err)
|
t.Fatalf("ParseNumberList returned error: %v", err)
|
||||||
}
|
}
|
||||||
want := []string{"1", "2", "3"}
|
want := []string{"1", "2", "3"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
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) {
|
func TestParseNumberListRejectsInvalidNumber(t *testing.T) {
|
||||||
if _, err := parseIssueNumbers("1,abc"); err == nil {
|
if _, err := common.ParseNumberList("1,abc"); err == nil {
|
||||||
t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number")
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||||
}
|
}
|
||||||
want := []string{"12", "13"}
|
want := []string{"12", "13"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||||
}
|
}
|
||||||
want := []string{"12", "13"}
|
want := []string{"12", "13"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
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")
|
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 {
|
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) {
|
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")
|
path := writeTempCSV(t, "number\n2\n3\n")
|
||||||
got, err := collectIssueNumbers("1,2", path)
|
got, err := common.CollectNumbers("1,2", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("collectIssueNumbers returned error: %v", err)
|
t.Fatalf("CollectNumbers returned error: %v", err)
|
||||||
}
|
}
|
||||||
want := []string{"1", "2", "3"}
|
want := []string{"1", "2", "3"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
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) {
|
func TestParseBool(t *testing.T) {
|
||||||
if !parseBool("true") {
|
if !common.ParseBool("true") {
|
||||||
t.Fatal("parseBool(true) = false, want true")
|
t.Fatal("ParseBool(true) = false, want true")
|
||||||
}
|
}
|
||||||
if parseBool("") {
|
if common.ParseBool("") {
|
||||||
t.Fatal("parseBool(empty) = true, want false")
|
t.Fatal("ParseBool(empty) = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,46 @@ type existingIssue struct {
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
newBatchCloseShortcut(),
|
newBatchCloseShortcut(),
|
||||||
|
newBatchUpdateShortcut(),
|
||||||
|
newBatchDestroyShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List issues",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -41,6 +74,24 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if s := ctx.Arg("state"); s != "" {
|
if s := ctx.Arg("state"); s != "" {
|
||||||
q.Set("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)
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -52,6 +103,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a new issue",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
{Name: "body", Short: "b", Usage: "Issue description"},
|
||||||
|
|
@ -82,6 +145,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if m := ctx.Arg("milestone"); m != "" {
|
if m := ctx.Arg("milestone"); m != "" {
|
||||||
body["fixed_version_id"] = 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)
|
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -92,6 +158,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View issue details",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{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",
|
Name: "close",
|
||||||
Description: "Close an issue",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -138,17 +227,49 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "update",
|
Name: "update",
|
||||||
Description: "Update an issue",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||||
{Name: "title", Short: "t", Usage: "New title"},
|
{Name: "title", Short: "t", Usage: "New title"},
|
||||||
{Name: "body", Short: "b", Usage: "New description"},
|
{Name: "body", Short: "b", Usage: "New description"},
|
||||||
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -161,8 +282,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
title := ctx.Arg("title")
|
title := ctx.Arg("title")
|
||||||
description := ctx.Arg("body")
|
description := ctx.Arg("body")
|
||||||
state := ctx.Arg("state")
|
state := ctx.Arg("state")
|
||||||
if title == "" && description == "" && state == "" {
|
milestone := ctx.Arg("milestone")
|
||||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
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)
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
|
@ -187,6 +313,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
body["status_id"] = statusID
|
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)
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -197,6 +335,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "comment",
|
Name: "comment",
|
||||||
Description: "Add a comment to an issue",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||||
|
|
@ -223,6 +369,199 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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)
|
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)
|
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",
|
Name: "list",
|
||||||
Description: "List organizations",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
|
@ -30,6 +39,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show organization details",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -45,6 +63,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "members",
|
Name: "members",
|
||||||
Description: "List organization 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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
|
@ -65,6 +92,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create an organization",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
||||||
{Name: "description", Short: "d", Usage: "Description"},
|
{Name: "description", Short: "d", Usage: "Description"},
|
||||||
|
|
@ -84,5 +120,39 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -10,13 +11,48 @@ import (
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
|
newBatchCloseShortcut(),
|
||||||
|
newBatchMergeShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List pull requests",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
|
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -28,6 +64,27 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if s := ctx.Arg("state"); s != "" {
|
if s := ctx.Arg("state"); s != "" {
|
||||||
q.Set("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)
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -38,11 +95,32 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a pull request",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "PR description"},
|
{Name: "body", Short: "b", Usage: "PR description"},
|
||||||
{Name: "head", Usage: "Source branch", Required: true},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -52,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
head, _ := ctx.RequireArg("head")
|
head, _ := ctx.RequireArg("head")
|
||||||
base := ctx.Arg("base")
|
base := ctx.Arg("base")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = "master"
|
base = common.GetDefaultBranch(ctx)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|
@ -62,6 +140,28 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if b := ctx.Arg("body"); b != "" {
|
if b := ctx.Arg("body"); b != "" {
|
||||||
payload["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)
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -72,6 +172,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View pull request details",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -90,9 +196,26 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "merge",
|
Name: "merge",
|
||||||
Description: "Merge a pull request",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -103,9 +226,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if method == "" {
|
if method == "" {
|
||||||
method = "merge"
|
method = "merge"
|
||||||
}
|
}
|
||||||
|
if err := validateMergeMethod(method); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"do": method,
|
"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)
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -116,6 +248,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "close",
|
Name: "close",
|
||||||
Description: "Close a pull request",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -134,24 +272,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "files",
|
Name: "files",
|
||||||
Description: "List changed files in a pull request",
|
Description: "List changed files in a pull request",
|
||||||
Flags: []common.Flag{
|
Long: `List all files changed in a pull request.
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
|
||||||
},
|
Shows the file path, change type (added, modified, deleted),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
and the number of additions and deletions for each file.`,
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
Example: ` # List files changed in pull request #42
|
||||||
return err
|
gitlink pr +files --id 42`,
|
||||||
}
|
|
||||||
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",
|
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -170,6 +296,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "versions",
|
Name: "versions",
|
||||||
Description: "List pull request patchset 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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -191,6 +323,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "version-diff",
|
Name: "version-diff",
|
||||||
Description: "Show diff for a pull request patchset version",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
|
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
|
||||||
|
|
@ -228,6 +368,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "reviews",
|
Name: "reviews",
|
||||||
Description: "List pull request 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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
|
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
|
||||||
|
|
@ -257,6 +406,19 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "review",
|
Name: "review",
|
||||||
Description: "Create a pull request 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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
|
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
|
||||||
|
|
@ -306,9 +468,210 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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",
|
Name: "comment",
|
||||||
Description: "Add a comment to a pull request",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Comment body", 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) {
|
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||||
data, ok := env.Data.(map[string]interface{})
|
data, ok := env.Data.(map[string]interface{})
|
||||||
if !ok {
|
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 {
|
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
shortcut := findPRShortcut(t, name)
|
shortcut := findPRShortcut(t, name)
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,31 @@ package shortcuts
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
"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/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/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/org"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
"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/user"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/util"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
"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.
|
// RegisterAll mounts all shortcut groups onto the root command.
|
||||||
|
|
@ -29,6 +43,20 @@ func RegisterAll(root *cobra.Command) {
|
||||||
"search": search.Shortcuts(),
|
"search": search.Shortcuts(),
|
||||||
"ci": ci.Shortcuts(),
|
"ci": ci.Shortcuts(),
|
||||||
"webhook": webhook.Shortcuts(),
|
"webhook": webhook.Shortcuts(),
|
||||||
|
"wiki": wiki.Shortcuts(),
|
||||||
|
"board": board.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{
|
descriptions := map[string]string{
|
||||||
|
|
@ -42,6 +70,20 @@ func RegisterAll(root *cobra.Command) {
|
||||||
"search": "Search operations",
|
"search": "Search operations",
|
||||||
"ci": "CI/CD operations",
|
"ci": "CI/CD operations",
|
||||||
"webhook": "Webhook operations",
|
"webhook": "Webhook operations",
|
||||||
|
"wiki": "Wiki operations",
|
||||||
|
"board": "Project board (kanban) 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 {
|
for name, shortcuts := range groups {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,28 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List releases",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -28,17 +47,36 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a release",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
||||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Release notes"},
|
{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"},
|
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
|
@ -56,6 +94,8 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
if t := ctx.Arg("target"); t != "" {
|
if t := ctx.Arg("target"); t != "" {
|
||||||
payload["target_commitish"] = t
|
payload["target_commitish"] = t
|
||||||
|
} else {
|
||||||
|
payload["target_commitish"] = common.GetDefaultBranch(ctx)
|
||||||
}
|
}
|
||||||
if ctx.Arg("prerelease") == "true" {
|
if ctx.Arg("prerelease") == "true" {
|
||||||
payload["prerelease"] = true
|
payload["prerelease"] = true
|
||||||
|
|
@ -70,6 +110,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View release details",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -88,6 +136,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a release",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -96,6 +149,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
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)
|
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||||
if delErr != nil {
|
if delErr != nil {
|
||||||
// GitLink API bug: delete succeeds but returns error status.
|
// GitLink API bug: delete succeeds but returns error status.
|
||||||
|
|
@ -104,16 +162,118 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if viewErr != nil {
|
if viewErr != nil {
|
||||||
// Release no longer exists — delete actually succeeded
|
// Release no longer exists — delete actually succeeded
|
||||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
"message": "删除成功",
|
"message": "Release deleted successfully",
|
||||||
}, nil))
|
}, nil))
|
||||||
}
|
}
|
||||||
// Release still exists — delete truly failed
|
// Release still exists — delete truly failed
|
||||||
return delErr
|
return delErr
|
||||||
}
|
}
|
||||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
"message": "删除成功",
|
"message": "Release deleted successfully",
|
||||||
}, nil))
|
}, 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
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
@ -12,6 +14,19 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List repositories for a user or organization",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
|
{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"},
|
{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",
|
Name: "info",
|
||||||
Description: "Show repository details",
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -55,6 +75,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a new repository",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||||
|
|
@ -97,6 +126,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "fork",
|
Name: "fork",
|
||||||
Description: "Fork a repository",
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -111,10 +145,21 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a repository",
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
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)
|
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -122,5 +167,529 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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",
|
Name: "repos",
|
||||||
Description: "Search repositories",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
keyword, _ := ctx.RequireArg("keyword")
|
||||||
|
|
@ -22,6 +41,15 @@ func Shortcuts() []*common.Shortcut {
|
||||||
q.Set("search", keyword)
|
q.Set("search", keyword)
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
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)
|
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -32,10 +60,25 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "users",
|
Name: "users",
|
||||||
Description: "Search 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{
|
Flags: []common.Flag{
|
||||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
keyword, _ := ctx.RequireArg("keyword")
|
||||||
|
|
@ -43,6 +86,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
q.Set("search", keyword)
|
q.Set("search", keyword)
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
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)
|
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
@ -11,6 +13,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "me",
|
Name: "me",
|
||||||
Description: "Show current authenticated user",
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -22,6 +30,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show user profile",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -37,5 +51,732 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List repository webhooks",
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -41,6 +46,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View webhook details",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -62,6 +73,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a repository webhook",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
|
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
|
||||||
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
|
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
|
||||||
|
|
@ -77,6 +98,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "update",
|
Name: "update",
|
||||||
Description: "Update a repository webhook while preserving unspecified fields when available",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
{Name: "url", Short: "u", Usage: "Webhook target URL"},
|
{Name: "url", Short: "u", Usage: "Webhook target URL"},
|
||||||
|
|
@ -93,6 +124,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a repository webhook",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -114,6 +150,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Description: "Trigger a test delivery for a webhook",
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
},
|
},
|
||||||
|
|
@ -135,6 +176,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{
|
{
|
||||||
Name: "tasks",
|
Name: "tasks",
|
||||||
Description: "List webhook delivery 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{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue