forked from Gitlink/gitlink-cli
fix: apply raw api header flags #1
53
README.md
53
README.md
|
|
@ -5,7 +5,7 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/).
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
|
||||
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
|
|
@ -27,8 +27,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
## Why gitlink-cli?
|
||||
|
||||
- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Org, Search, and User workflows are covered by high-level commands
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
|
||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
|
||||
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
||||
|
|
@ -43,6 +43,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
|
||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||
| 🏷️ Release | Create, view, delete releases |
|
||||
| 🏢 Org | Manage organizations, members, teams |
|
||||
|
|
@ -70,7 +71,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
**From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# One command: installs CLI binary + all 13 AI Agent Skills
|
||||
# One command: installs CLI binary + AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
|
|
@ -151,6 +152,9 @@ gitlink-cli repo +list
|
|||
# View repository info
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# Read repository README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# Create a repository
|
||||
gitlink-cli repo +create -n my-project -d "Project description"
|
||||
|
||||
|
|
@ -175,6 +179,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
|||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Member Management
|
||||
|
||||
```bash
|
||||
# List repository members
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Add a member
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# Preview batch add without changing data
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# Batch add members from a CSV file
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# Change a member role
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# Create an invite link
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
|
|
@ -198,6 +224,12 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
|||
|
||||
# Add a comment
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||
|
||||
# List issue assigners
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue authors
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
|
@ -218,6 +250,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|||
# Merge a PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Reopen a closed PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View changed files
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
|
|
@ -400,6 +435,12 @@ gitlink-cli api GET /users/me
|
|||
# POST request
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# POST request with body from a file
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# POST request with body from stdin
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# With query parameters
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -437,7 +478,7 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
|
||||
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
|
||||
|
||||
See [skills/README.md](skills/README.md) for details.
|
||||
|
||||
|
|
@ -447,6 +488,7 @@ See [skills/README.md](skills/README.md) for details.
|
|||
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
|
||||
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
||||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
||||
| `gitlink-member` | Repository member and invite link management |
|
||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
|
|
@ -477,6 +519,7 @@ gitlink-cli/
|
|||
│ ├── repo/ # Repository shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # Repository member shortcuts
|
||||
│ ├── branch/ # Branch shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # Organization shortcuts
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 AI Agent [Skills](./skills/)。
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
|
||||
|
||||
**[English](./README.md)**
|
||||
|
||||
|
|
@ -27,8 +27,8 @@
|
|||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||
| 🌿 分支 | 创建、删除、保护分支 |
|
||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
|
|
@ -162,6 +163,9 @@ gitlink-cli repo +list
|
|||
# 查看仓库信息
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# 读取仓库 README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
|
||||
|
|
@ -186,6 +190,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
|||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```bash
|
||||
# 列出仓库成员
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加成员
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# 预览批量添加成员,不修改数据
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# 从 CSV 文件批量添加成员
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# 调整成员权限
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# 生成邀请链接
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
```bash
|
||||
|
|
@ -209,6 +235,12 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
|||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
|
||||
# 列出 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 发布人
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Pull Request
|
||||
|
|
@ -229,6 +261,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|||
# 合并 PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 重开已关闭的 PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
|
|
@ -280,6 +315,12 @@ gitlink-cli api GET /users/me
|
|||
# POST 请求
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# 从文件读取 JSON body
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# 从 stdin 读取 JSON body
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# 带查询参数
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -317,7 +358,7 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
|
||||
|
|
@ -327,6 +368,7 @@ git push gitlink
|
|||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
|
|
@ -356,6 +398,7 @@ gitlink-cli/
|
|||
│ ├── repo/ # 仓库 shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # 仓库成员 shortcuts
|
||||
│ ├── branch/ # 分支 shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # 组织 shortcuts
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ package api
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -20,12 +23,15 @@ func NewAPICmd() *cobra.Command {
|
|||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
||||
apiCmd.Flags().String("body-file", "", "Read request body JSON from a file")
|
||||
apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin")
|
||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
||||
|
||||
|
|
@ -46,12 +52,9 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
var body interface{}
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
if bodyStr != "" {
|
||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
||||
return fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
body, err := readJSONBody(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
|
|
@ -64,7 +67,12 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
headers, err := parseHeaders(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
env, err := cli.DoWithHeaders(method, path, body, query, headers)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
|
|
@ -76,6 +84,70 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
func parseHeaders(c *cobra.Command) (http.Header, error) {
|
||||
values, _ := c.Flags().GetStringSlice("header")
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
for _, value := range values {
|
||||
key, headerValue, ok := strings.Cut(value, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid header %q: expected key:value", value)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid header %q: header key is empty", value)
|
||||
}
|
||||
headers.Add(key, strings.TrimSpace(headerValue))
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
|
||||
sources := 0
|
||||
if bodyStr != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyFile != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyStdin {
|
||||
sources++
|
||||
}
|
||||
if sources == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if sources > 1 {
|
||||
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
switch {
|
||||
case bodyStr != "":
|
||||
data = []byte(bodyStr)
|
||||
case bodyFile != "":
|
||||
data, err = os.ReadFile(bodyFile)
|
||||
case bodyStdin:
|
||||
data, err = io.ReadAll(c.InOrStdin())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JSON body: %w", err)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
f := cmdutil.Format
|
||||
if f == "" {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadJSONBodyFromInlineFlag(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("body", `{"title":"hello","count":2}`)
|
||||
|
||||
body, err := readJSONBody(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("readJSONBody returned error: %v", err)
|
||||
}
|
||||
values := body.(map[string]interface{})
|
||||
if values["title"] != "hello" {
|
||||
t.Fatalf("title = %v, want hello", values["title"])
|
||||
}
|
||||
if values["count"] != float64(2) {
|
||||
t.Fatalf("count = %v, want 2", values["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONBodyFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "body.json")
|
||||
if err := os.WriteFile(path, []byte(`{"description":"来自文件"}`), 0o600); err != nil {
|
||||
t.Fatalf("write body file: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("body-file", path)
|
||||
|
||||
body, err := readJSONBody(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("readJSONBody returned error: %v", err)
|
||||
}
|
||||
values := body.(map[string]interface{})
|
||||
if values["description"] != "来自文件" {
|
||||
t.Fatalf("description = %v, want 来自文件", values["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONBodyFromStdin(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("body-stdin", "true")
|
||||
cmd.SetIn(strings.NewReader(`{"notes":"from stdin"}`))
|
||||
|
||||
body, err := readJSONBody(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("readJSONBody returned error: %v", err)
|
||||
}
|
||||
values := body.(map[string]interface{})
|
||||
if values["notes"] != "from stdin" {
|
||||
t.Fatalf("notes = %v, want from stdin", values["notes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONBodyRejectsMultipleSources(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("body", `{"title":"hello"}`)
|
||||
cmd.Flags().Set("body-stdin", "true")
|
||||
|
||||
if _, err := readJSONBody(cmd); err == nil {
|
||||
t.Fatal("expected multiple body sources to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONBodyRejectsInvalidJSON(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("body", `{"title":`)
|
||||
|
||||
if _, err := readJSONBody(cmd); err == nil {
|
||||
t.Fatal("expected invalid JSON to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONBodyWithoutSource(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
|
||||
body, err := readJSONBody(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("readJSONBody returned error: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Fatalf("body = %v, want nil", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeaders(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("header", "X-Test: value")
|
||||
cmd.Flags().Set("header", "X-Trace: one:two")
|
||||
|
||||
headers, err := parseHeaders(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("parseHeaders returned error: %v", err)
|
||||
}
|
||||
if got := headers.Get("X-Test"); got != "value" {
|
||||
t.Fatalf("X-Test = %q, want value", got)
|
||||
}
|
||||
if got := headers.Get("X-Trace"); got != "one:two" {
|
||||
t.Fatalf("X-Trace = %q, want one:two", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeadersRejectsInvalidValue(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("header", "missing-colon")
|
||||
|
||||
if _, err := parseHeaders(cmd); err == nil {
|
||||
t.Fatal("expected invalid header to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeadersRejectsEmptyKey(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
cmd.Flags().Set("header", ": value")
|
||||
|
||||
if _, err := parseHeaders(cmd); err == nil {
|
||||
t.Fatal("expected empty header key to return an error")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Member Shortcut
|
||||
|
||||
新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作:
|
||||
|
||||
- `member +list`
|
||||
- `member +add`
|
||||
- `member +batch-add`
|
||||
- `member +remove`
|
||||
- `member +role`
|
||||
- `member +invite-link`
|
||||
- `member +invite-info`
|
||||
- `member +accept-invite`
|
||||
|
||||
同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Milestone shortcut
|
||||
|
||||
新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装:
|
||||
|
||||
- `milestone +list`
|
||||
- `milestone +create`
|
||||
- `milestone +view`
|
||||
- `milestone +update`
|
||||
- `milestone +delete`
|
||||
- `milestone +close`
|
||||
- `milestone +reopen`
|
||||
|
||||
实现要点:
|
||||
|
||||
- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。
|
||||
- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。
|
||||
- `+update` 在没有任何变更字段时直接报错,避免发送空更新。
|
||||
- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。
|
||||
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 GitLink Workflow Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# GitLink 构建端到端自动化工作流
|
||||
|
||||
面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。
|
||||
|
||||
本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集,自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/gitlink_workflow.py`:主工作流入口
|
||||
- `scripts/run_demo.ps1`:一键复现脚本
|
||||
- `docs/architecture.md`:架构图与流程说明
|
||||
- `docs/quickstart.md`:最短复现路径
|
||||
- `docs/runbook.md`:运行手册
|
||||
- `docs/verification.md`:真实仓库验证记录
|
||||
- `docs/submission-checklist.md`:参赛提交核对清单
|
||||
- `docs/upload-to-gitlink.md`:仓库目录结构说明
|
||||
- `examples/sample_config.json`:参赛仓库配置
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置
|
||||
- `examples/demo_outputs/`:真实运行示例产物
|
||||
- `tests/test_gitlink_workflow.py`:单测
|
||||
- `LICENSE`:Apache 2.0
|
||||
|
||||
## 运行方式
|
||||
|
||||
推荐直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
切换到参赛仓库配置:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Config examples\sample_config.json
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证仓库
|
||||
|
||||
- `puygob236/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并生成包含有效统计数据的周报、Release Notes 和结构化摘要
|
||||
|
||||
## 项目定位
|
||||
|
||||
- 满足子赛题三“端到端自动化工作流”的要求
|
||||
- 串联 4 个数据采集命令和 1 个结果发布命令
|
||||
- 支持在真实 GitLink 项目上复现
|
||||
- 提供运行脚本、验证记录、示例产物和单元测试
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 架构说明
|
||||
|
||||
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
|
||||
|
||||

|
||||
|
||||
## 设计目标
|
||||
|
||||
- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
|
||||
- 可复现:同一配置可重复跑出同类报告
|
||||
- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
|
||||
- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
|
||||
|
||||
## 为什么选这个链路
|
||||
|
||||
子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
|
||||
|
||||
1. 仓库信息采集
|
||||
2. Issue 列表采集
|
||||
3. PR 列表采集
|
||||
4. Release 列表采集
|
||||
5. 报告生成
|
||||
6. Issue 摘要发布
|
||||
|
||||
该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 400 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
# 示例输出摘要
|
||||
|
||||
## 验证目标
|
||||
|
||||
`Gitlink/gitlink-cli`
|
||||
|
||||
## 运行命令
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 关键结果
|
||||
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
- 输出文件:
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
|
||||
## 仓库内示例产物
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 额外验证
|
||||
|
||||
`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 快速开始
|
||||
|
||||
## 一键运行
|
||||
|
||||
直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行:
|
||||
|
||||
- 仓库信息采集
|
||||
- Issue 列表采集
|
||||
- PR 列表采集
|
||||
- Release 列表采集
|
||||
- 周报生成
|
||||
- Release Notes 草稿生成
|
||||
|
||||
## 配置切换
|
||||
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli`
|
||||
- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli`
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证事实
|
||||
|
||||
- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# 运行手册
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 `gitlink-cli`
|
||||
- 已完成 `gitlink-cli auth login`
|
||||
- 目标仓库有可读权限
|
||||
|
||||
官方快速开始里要求的验证命令是:
|
||||
|
||||
```powershell
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
## 运行方式
|
||||
|
||||
### 1. 只生成报告
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json
|
||||
```
|
||||
|
||||
### 2. 生成报告并发布摘要
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123
|
||||
```
|
||||
|
||||
### 3. 一键复现
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 输出文件
|
||||
|
||||
- `outputs/*_report.md`:完整周报
|
||||
- `outputs/*_release_notes.md`:Release Notes 草稿
|
||||
- `outputs/*_summary.json`:结构化摘要
|
||||
|
||||
## 验证清单
|
||||
|
||||
- `repo +info` 能返回仓库信息
|
||||
- `issue +list` 能返回 Issue 列表
|
||||
- `pr +list` 能返回 PR 列表
|
||||
- `release +list` 能返回 Release 列表
|
||||
- 报告文件能落盘
|
||||
- Release Notes 草稿能落盘
|
||||
- 发布模式能把摘要写回指定 Issue
|
||||
|
||||
## 真实项目配置
|
||||
|
||||
- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。
|
||||
- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 |
|
||||
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
|
||||
| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` |
|
||||
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
|
||||
| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` |
|
||||
| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli` 的 `examples/workflows/community-ops-automation/` |
|
||||
| 提供完整中文 README | `README.md` |
|
||||
| 开源协议 | `LICENSE`,Apache 2.0 |
|
||||
|
||||
## 验证状态
|
||||
|
||||
- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过
|
||||
- `python -m unittest discover -s tests`:通过
|
||||
- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`。
|
||||
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
|
||||
- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# GitLink 仓库目录结构
|
||||
|
||||
本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中,目录与主项目源码保持隔离,避免改变主仓库既有命令、Skill 和设计文档结构。
|
||||
|
||||
## 作品路径
|
||||
|
||||
```text
|
||||
examples/workflows/community-ops-automation/
|
||||
```
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `README.md`:项目说明与复现入口
|
||||
- `LICENSE`:Apache 2.0 开源协议
|
||||
- `.gitignore`:运行时产物忽略规则
|
||||
- `docs/`:架构、运行、验证和交付说明
|
||||
- `examples/`:配置文件和示例输出
|
||||
- `scripts/`:工作流执行脚本
|
||||
- `tests/`:单元测试
|
||||
|
||||
## 仓库内验证
|
||||
|
||||
进入作品目录后运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`。
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# 验证记录
|
||||
|
||||
## 环境
|
||||
|
||||
- Windows PowerShell
|
||||
- Python 3
|
||||
- `@gitlink-ai/cli` 0.1.13
|
||||
|
||||
## 已验证的真实仓库
|
||||
|
||||
### `puygob236/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 已完成 Issue 摘要回写验证
|
||||
|
||||
### `Gitlink/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 当前可提取到的统计结果:
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
|
||||
## 本地输出
|
||||
|
||||
已生成的文件:
|
||||
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json`
|
||||
|
||||
其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。
|
||||
|
||||
## 示例产物
|
||||
|
||||
`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例:
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 复现方式
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:8 个
|
||||
- 最近窗口内合并 PR:2 个
|
||||
|
||||
## 变更分类
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
|
||||
## 发布说明
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 15 |
|
||||
| 打开 Issues | 5 |
|
||||
| 超窗 Issue | 1 |
|
||||
| PR 总数 | 20 |
|
||||
| 打开 PR | 5 |
|
||||
| 已合并 PR | 8 |
|
||||
| Release 数 | 11 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
## 风险提示
|
||||
### 超窗 Issue
|
||||
- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18
|
||||
|
||||
### 建议动作
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# 示例输出说明
|
||||
|
||||
本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。
|
||||
|
||||
- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例
|
||||
- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例
|
||||
- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例
|
||||
- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例
|
||||
|
||||
完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:0 个
|
||||
- 最近窗口内合并 PR:0 个
|
||||
|
||||
## 变更分类
|
||||
- 无
|
||||
|
||||
## 发布说明
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 2 |
|
||||
| 打开 Issues | 2 |
|
||||
| 超窗 Issue | 0 |
|
||||
| PR 总数 | 0 |
|
||||
| 打开 PR | 0 |
|
||||
| 已合并 PR | 0 |
|
||||
| Release 数 | 0 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
- 无
|
||||
|
||||
## 风险提示
|
||||
### 建议动作
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "puygob236",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,814 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class WorkflowError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CLI_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("examples/sample_config.json"),
|
||||
help="配置文件路径",
|
||||
)
|
||||
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
|
||||
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
|
||||
parser.add_argument(
|
||||
"--window-days",
|
||||
type=int,
|
||||
help="统计窗口,默认从配置文件读取或使用 7 天",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
help="输出目录,默认从配置文件读取或使用 outputs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish-issue-id",
|
||||
type=int,
|
||||
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
help="固定当前时间,便于测试,格式为 ISO8601",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-releases",
|
||||
action="store_true",
|
||||
help="跳过 release 列表采集",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cli-bin",
|
||||
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_json_file(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def sanitize_repo_name(value: str) -> str:
|
||||
return value.replace("/", "_").replace("\\", "_")
|
||||
|
||||
|
||||
def parse_datetime(value: Any) -> datetime | None:
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def parse_iso_now(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.now(timezone.utc)
|
||||
dt = parse_datetime(value)
|
||||
if dt is None:
|
||||
raise WorkflowError(f"无法解析 --now 的值: {value}")
|
||||
return dt
|
||||
|
||||
|
||||
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||||
for key in keys:
|
||||
if key in item:
|
||||
value = item[key]
|
||||
if value not in (None, "", []):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def normalize_labels(value: Any) -> list[str]:
|
||||
labels: list[str] = []
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
name = first_value(item, ("name", "title", "label_name"))
|
||||
if name:
|
||||
labels.append(str(name))
|
||||
elif item not in (None, ""):
|
||||
labels.append(str(item))
|
||||
elif isinstance(value, str) and value:
|
||||
labels.append(value)
|
||||
return labels
|
||||
|
||||
|
||||
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_list(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_dict(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = extract_first_dict(item, keys)
|
||||
if found:
|
||||
return found
|
||||
return {}
|
||||
|
||||
|
||||
def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any:
|
||||
if shutil_which("gitlink-cli") is None:
|
||||
raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中")
|
||||
|
||||
cli_path = shutil_which("gitlink-cli") or "gitlink-cli"
|
||||
if cli_path.lower().endswith((".cmd", ".bat")):
|
||||
cmd = [
|
||||
"cmd",
|
||||
"/c",
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
|
||||
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
|
||||
return parse_json_output(proc.stdout)
|
||||
|
||||
|
||||
def parse_json_output(text: str) -> Any:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
raise WorkflowError("CLI 返回空结果")
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
first_json = min(
|
||||
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
|
||||
default=-1,
|
||||
)
|
||||
if first_json > 0:
|
||||
return json.loads(stripped[first_json:])
|
||||
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
|
||||
|
||||
|
||||
def normalize_repo_info(payload: Any) -> dict[str, Any]:
|
||||
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
|
||||
if not repo and isinstance(payload, dict):
|
||||
repo = payload
|
||||
return {
|
||||
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
|
||||
"description": first_value(repo, ("description", "desc", "summary"), ""),
|
||||
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
|
||||
"language": first_value(repo, ("language",), ""),
|
||||
"raw": repo,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
|
||||
raw_name = str(
|
||||
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
|
||||
).strip().lower()
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name:
|
||||
return "closed"
|
||||
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
|
||||
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
|
||||
"state": normalize_issue_state(item, query_state=query_state),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
|
||||
),
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_issue(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {1, "1", "merged"}:
|
||||
return "merged"
|
||||
if raw_status in {2, "2", "closed", "close"}:
|
||||
return "closed"
|
||||
if raw_status in {0, "0", "open", "opened"}:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
state = normalize_pr_state(item, query_state=query_state)
|
||||
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
|
||||
merged_flag = state == "merged" or merged_at is not None
|
||||
return {
|
||||
"id": str(
|
||||
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
|
||||
),
|
||||
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
|
||||
"state": state,
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
|
||||
),
|
||||
"merged_at": merged_at
|
||||
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
|
||||
"merged": merged_flag,
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_pr(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("releases", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
|
||||
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
|
||||
),
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def is_open(state: str) -> bool:
|
||||
return state == "open"
|
||||
|
||||
|
||||
def is_closed(state: str) -> bool:
|
||||
return state in {"closed", "close", "done", "resolved"}
|
||||
|
||||
|
||||
def classify_title(title: str) -> str:
|
||||
lowered = title.strip().lower()
|
||||
prefix = lowered.split(":", 1)[0]
|
||||
prefix = prefix.split("(", 1)[0].strip()
|
||||
mapping = {
|
||||
"feat": "feature",
|
||||
"feature": "feature",
|
||||
"fix": "fix",
|
||||
"bugfix": "fix",
|
||||
"docs": "docs",
|
||||
"doc": "docs",
|
||||
"refactor": "refactor",
|
||||
"test": "test",
|
||||
"chore": "chore",
|
||||
"ci": "ci",
|
||||
}
|
||||
return mapping.get(prefix, "other")
|
||||
|
||||
|
||||
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
|
||||
return dt is not None and dt >= cutoff
|
||||
|
||||
|
||||
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in records:
|
||||
key = str(item.get("id", "")).strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_paginated_payload(
|
||||
command: list[str],
|
||||
owner: str,
|
||||
repo: str,
|
||||
item_keys: tuple[str, ...],
|
||||
page_size: int = CLI_PAGE_SIZE,
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
max_pages = 50
|
||||
while True:
|
||||
if page > max_pages:
|
||||
break
|
||||
payload = run_gitlink_cli(
|
||||
[*command, "--page", str(page), "--limit", str(page_size)],
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
page_items = extract_first_list(payload, item_keys)
|
||||
page_items = [item for item in page_items if isinstance(item, dict)]
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
if len(page_items) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["issue", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("issues", "issue_list", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_issues({"issues": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "merged", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["pr", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("pull_requests", "merge_requests", "prs", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
payloads = fetch_paginated_payload(
|
||||
["release", "+list"],
|
||||
owner,
|
||||
repo,
|
||||
("releases", "items", "list"),
|
||||
)
|
||||
return dedupe_records(normalize_releases({"releases": payloads}))
|
||||
|
||||
|
||||
def summarize_workflow(
|
||||
repo_info: dict[str, Any],
|
||||
issues: list[dict[str, Any]],
|
||||
prs: list[dict[str, Any]],
|
||||
releases: list[dict[str, Any]],
|
||||
now: datetime,
|
||||
window_days: int,
|
||||
) -> dict[str, Any]:
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
|
||||
open_issues = [item for item in issues if is_open(item["state"])]
|
||||
closed_issues = [item for item in issues if is_closed(item["state"])]
|
||||
stale_issues = [
|
||||
item
|
||||
for item in open_issues
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
|
||||
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
|
||||
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
|
||||
stale_prs = [
|
||||
item
|
||||
for item in open_prs
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
recent_merged_prs = [
|
||||
item
|
||||
for item in merged_prs
|
||||
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
|
||||
]
|
||||
|
||||
issue_label_counter: Counter[str] = Counter()
|
||||
for item in issues:
|
||||
issue_label_counter.update(item["labels"])
|
||||
|
||||
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in recent_merged_prs:
|
||||
pr_buckets[classify_title(item["title"])].append(item)
|
||||
|
||||
actions: list[str] = []
|
||||
if stale_issues:
|
||||
actions.append(
|
||||
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。"
|
||||
)
|
||||
if stale_prs:
|
||||
actions.append(
|
||||
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。"
|
||||
)
|
||||
if not releases:
|
||||
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
|
||||
|
||||
return {
|
||||
"repo": repo_info,
|
||||
"window_days": window_days,
|
||||
"now": now,
|
||||
"cutoff": cutoff,
|
||||
"counts": {
|
||||
"issues_total": len(issues),
|
||||
"issues_open": len(open_issues),
|
||||
"issues_closed": len(closed_issues),
|
||||
"issues_stale": len(stale_issues),
|
||||
"prs_total": len(prs),
|
||||
"prs_open": len(open_prs),
|
||||
"prs_merged": len(merged_prs),
|
||||
"prs_stale": len(stale_prs),
|
||||
"releases_total": len(releases),
|
||||
},
|
||||
"labels": issue_label_counter.most_common(8),
|
||||
"stale_issues": stale_issues,
|
||||
"stale_prs": stale_prs,
|
||||
"recent_merged_prs": recent_merged_prs,
|
||||
"pr_buckets": {key: value for key, value in pr_buckets.items()},
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
|
||||
if not items:
|
||||
return "- 无"
|
||||
lines = []
|
||||
for item in items[:10]:
|
||||
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
|
||||
state = item.get("state")
|
||||
if state:
|
||||
parts.append(f"({state})")
|
||||
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
|
||||
if isinstance(dt, datetime):
|
||||
parts.append(dt.strftime("%Y-%m-%d"))
|
||||
lines.append(" ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_markdown_report(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} 自动化周报")
|
||||
if repo.get("description"):
|
||||
lines.append("")
|
||||
lines.append(repo["description"])
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 核心指标")
|
||||
lines.append("")
|
||||
lines.append("| 指标 | 数值 |")
|
||||
lines.append("| --- | ---: |")
|
||||
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
|
||||
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
|
||||
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
|
||||
lines.append(f"| PR 总数 | {counts['prs_total']} |")
|
||||
lines.append(f"| 打开 PR | {counts['prs_open']} |")
|
||||
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
|
||||
lines.append(f"| Release 数 | {counts['releases_total']} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 热点标签")
|
||||
if summary["labels"]:
|
||||
for label, count in summary["labels"]:
|
||||
lines.append(f"- {label}: {count}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 最近合并 PR")
|
||||
recent_groups = summary["pr_buckets"]
|
||||
if recent_groups:
|
||||
for bucket, items in recent_groups.items():
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:8]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 风险提示")
|
||||
if summary["stale_issues"]:
|
||||
lines.append("### 超窗 Issue")
|
||||
lines.append(render_list_block(summary["stale_issues"]))
|
||||
lines.append("")
|
||||
if summary["stale_prs"]:
|
||||
lines.append("### 超窗 PR")
|
||||
lines.append(render_list_block(summary["stale_prs"]))
|
||||
lines.append("")
|
||||
if summary["actions"]:
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_release_notes(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} Release Notes 草稿")
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 变更概览")
|
||||
lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个")
|
||||
lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个")
|
||||
lines.append("")
|
||||
lines.append("## 变更分类")
|
||||
groups = summary["pr_buckets"]
|
||||
if groups:
|
||||
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
|
||||
items = groups.get(bucket, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:10]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
lines.append("## 发布说明")
|
||||
if summary["actions"]:
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_publish_comment(
|
||||
summary: dict[str, Any],
|
||||
report_path: Path,
|
||||
release_notes_path: Path | None = None,
|
||||
) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines = [
|
||||
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
|
||||
"",
|
||||
f"- 时间窗:近 {summary['window_days']} 天",
|
||||
f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
|
||||
f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
|
||||
f"- Release:{counts['releases_total']} 条",
|
||||
"",
|
||||
f"完整报告已生成:`{report_path.as_posix()}`",
|
||||
]
|
||||
if release_notes_path is not None:
|
||||
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
|
||||
if summary["actions"]:
|
||||
lines.append("")
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"][:3]:
|
||||
lines.append(f"- {action}")
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
|
||||
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
|
||||
|
||||
|
||||
def safe_fetch(
|
||||
label: str,
|
||||
func,
|
||||
warnings: list[str],
|
||||
default: Any,
|
||||
) -> Any:
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"{label} 失败:{exc}")
|
||||
return default
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
from shutil import which
|
||||
|
||||
return which(name)
|
||||
|
||||
|
||||
def build_artifacts(
|
||||
owner: str,
|
||||
repo: str,
|
||||
window_days: int,
|
||||
output_dir: Path,
|
||||
now: datetime,
|
||||
publish_issue_id: int | None,
|
||||
skip_releases: bool,
|
||||
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
|
||||
warnings: list[str] = []
|
||||
repo_info = safe_fetch(
|
||||
"repo +info",
|
||||
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)),
|
||||
warnings,
|
||||
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
|
||||
)
|
||||
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, [])
|
||||
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, [])
|
||||
releases = [] if skip_releases else safe_fetch(
|
||||
"release +list",
|
||||
lambda: fetch_releases(owner, repo),
|
||||
warnings,
|
||||
[],
|
||||
)
|
||||
|
||||
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
|
||||
summary["warnings"] = warnings
|
||||
summary["owner"] = owner
|
||||
summary["repo_name"] = repo
|
||||
summary["publish_issue_id"] = publish_issue_id
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
repo_slug = sanitize_repo_name(repo)
|
||||
base_name = f"{owner}_{repo_slug}_{stamp}"
|
||||
report_path = output_dir / f"{base_name}_report.md"
|
||||
summary_path = output_dir / f"{base_name}_summary.json"
|
||||
release_notes_path = output_dir / f"{base_name}_release_notes.md"
|
||||
|
||||
report_text = render_markdown_report(summary)
|
||||
release_notes_text = render_release_notes(summary)
|
||||
report_path.write_text(report_text, encoding="utf-8")
|
||||
release_notes_path.write_text(release_notes_text, encoding="utf-8")
|
||||
summary_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**summary,
|
||||
"now": summary["now"].isoformat(),
|
||||
"cutoff": summary["cutoff"].isoformat(),
|
||||
"artifacts": {
|
||||
"report": report_path.as_posix(),
|
||||
"summary": summary_path.as_posix(),
|
||||
"release_notes": release_notes_path.as_posix(),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if publish_issue_id is not None:
|
||||
comment = render_publish_comment(summary, report_path, release_notes_path)
|
||||
try:
|
||||
run_gitlink_cli(
|
||||
build_issue_comment_command(publish_issue_id, comment),
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"issue +comment 失败:{exc}")
|
||||
|
||||
return summary, report_path, summary_path, release_notes_path, warnings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
config = load_json_file(args.config)
|
||||
|
||||
owner = args.owner or config.get("owner")
|
||||
repo = args.repo or config.get("repo")
|
||||
if not owner or not repo:
|
||||
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
|
||||
|
||||
window_days = args.window_days or int(config.get("window_days", 7))
|
||||
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
|
||||
now = parse_iso_now(args.now)
|
||||
|
||||
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
window_days=window_days,
|
||||
output_dir=output_dir,
|
||||
now=now,
|
||||
publish_issue_id=args.publish_issue_id,
|
||||
skip_releases=args.skip_releases,
|
||||
)
|
||||
|
||||
print(f"已生成报告: {report_path}")
|
||||
print(f"已生成摘要: {summary_path}")
|
||||
print(f"已生成 Release Notes: {release_notes_path}")
|
||||
if warnings:
|
||||
print("警告:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
print(
|
||||
"指标概览: "
|
||||
f"Issues={summary['counts']['issues_total']}, "
|
||||
f"PR={summary['counts']['prs_total']}, "
|
||||
f"Release={summary['counts']['releases_total']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
param(
|
||||
[string]$Config = "examples/demo_active_config.json",
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[int]$WindowDays = 7,
|
||||
[string]$OutputDir = "outputs",
|
||||
[int]$PublishIssueId = 0,
|
||||
[switch]$SkipReleases
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
|
||||
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
|
||||
if (-not $cliPath) {
|
||||
$cliPath = $cliCandidates | Select-Object -First 1
|
||||
}
|
||||
if (-not $cliPath) {
|
||||
throw "未能通过 npm exec 找到 gitlink-cli"
|
||||
}
|
||||
|
||||
$cliDir = Split-Path -Parent $cliPath
|
||||
$env:PATH = "$cliDir;$env:PATH"
|
||||
|
||||
$args = @(
|
||||
"scripts\gitlink_workflow.py",
|
||||
"--config", $Config,
|
||||
"--window-days", "$WindowDays",
|
||||
"--output-dir", $OutputDir
|
||||
)
|
||||
|
||||
if ($Owner) {
|
||||
$args += @("--owner", $Owner)
|
||||
}
|
||||
if ($Repo) {
|
||||
$args += @("--repo", $Repo)
|
||||
}
|
||||
if ($PublishIssueId -gt 0) {
|
||||
$args += @("--publish-issue-id", "$PublishIssueId")
|
||||
}
|
||||
if ($SkipReleases.IsPresent) {
|
||||
$args += "--skip-releases"
|
||||
}
|
||||
|
||||
python @args
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scripts.gitlink_workflow import (
|
||||
build_issue_comment_command,
|
||||
normalize_issues,
|
||||
normalize_prs,
|
||||
normalize_releases,
|
||||
render_markdown_report,
|
||||
render_release_notes,
|
||||
summarize_workflow,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
|
||||
self.repo_info = {
|
||||
"name": "forgeplus",
|
||||
"description": "demo repo",
|
||||
"default_branch": "master",
|
||||
}
|
||||
|
||||
def test_normalize_issue_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"issues": [
|
||||
{
|
||||
"project_issues_index": 1,
|
||||
"subject": "feat: add report",
|
||||
"status_id": 1,
|
||||
"status_name": "新增",
|
||||
"updated_at": "2026-05-10T10:00:00Z",
|
||||
"labels": [{"name": "enhancement"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
issues = normalize_issues(payload)
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["title"], "feat: add report")
|
||||
self.assertEqual(issues[0]["labels"], ["enhancement"])
|
||||
self.assertEqual(issues[0]["state"], "open")
|
||||
|
||||
def test_normalize_pr_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"merge_requests": [
|
||||
{
|
||||
"pull_request_number": 10,
|
||||
"title": "fix: bug",
|
||||
"pull_request_status": 1,
|
||||
"merged_at": "2026-05-14T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
prs = normalize_prs(payload)
|
||||
self.assertEqual(len(prs), 1)
|
||||
self.assertTrue(prs[0]["merged"])
|
||||
self.assertEqual(prs[0]["state"], "merged")
|
||||
|
||||
def test_normalize_release_payload(self) -> None:
|
||||
payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}}
|
||||
releases = normalize_releases(payload)
|
||||
self.assertEqual(len(releases), 1)
|
||||
self.assertEqual(releases[0]["title"], "v1.0.0")
|
||||
|
||||
def test_summary_and_report(self) -> None:
|
||||
issues = [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "feat: add report",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["enhancement"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "fix: stale issue",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["bug"],
|
||||
},
|
||||
]
|
||||
prs = [
|
||||
{
|
||||
"id": "10",
|
||||
"title": "feat: workflow",
|
||||
"state": "merged",
|
||||
"created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged": True,
|
||||
"labels": [],
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"title": "chore: cleanup",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": None,
|
||||
"merged": False,
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}]
|
||||
summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7)
|
||||
report = render_markdown_report(summary)
|
||||
self.assertIn("# forgeplus 自动化周报", report)
|
||||
self.assertIn("Issues 总数", report)
|
||||
self.assertIn("超窗 Issue", report)
|
||||
self.assertIn("feature", report)
|
||||
release_notes = render_release_notes(summary)
|
||||
self.assertIn("Release Notes", release_notes)
|
||||
self.assertIn("变更分类", release_notes)
|
||||
self.assertEqual(summary["counts"]["issues_stale"], 1)
|
||||
self.assertEqual(summary["counts"]["prs_merged"], 1)
|
||||
self.assertIn("feature", summary["pr_buckets"])
|
||||
|
||||
def test_issue_comment_command_uses_number_flag(self) -> None:
|
||||
command = build_issue_comment_command(2, "demo")
|
||||
self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"])
|
||||
self.assertNotIn("-i", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -42,6 +42,12 @@ func New() (*Client, error) {
|
|||
}
|
||||
|
||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
return c.DoWithHeaders(method, path, body, query, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DoWithHeaders(method, path string, body interface{}, query url.Values, headers http.Header) (*output.Envelope, error) {
|
||||
path = normalizeAPIPath(c.BaseURL, path)
|
||||
|
||||
// Append .json suffix if not already present (GitLink API convention)
|
||||
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
|
|
@ -76,6 +82,11 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
|
|
@ -158,6 +169,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return output.SuccessEnvelope(raw, meta), nil
|
||||
}
|
||||
|
||||
func normalizeAPIPath(baseURL, path string) string {
|
||||
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
|
||||
switch {
|
||||
case path == "/api":
|
||||
return ""
|
||||
case strings.HasPrefix(path, "/api/"):
|
||||
return strings.TrimPrefix(path, "/api")
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("GET", path, nil, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) {
|
||||
got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md")
|
||||
want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) {
|
||||
got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects")
|
||||
want := "/projects"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) {
|
||||
got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli")
|
||||
want := "/api/v1/repos/Gitlink/gitlink-cli"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithHeadersAddsRequestHeaders(t *testing.T) {
|
||||
var gotHeader string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("X-Test")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":1,"data":{"ok":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cli := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
headers := http.Header{}
|
||||
headers.Set("X-Test", "value")
|
||||
|
||||
if _, err := cli.DoWithHeaders("GET", "/users/me", nil, nil, headers); err != nil {
|
||||
t.Fatalf("DoWithHeaders returned error: %v", err)
|
||||
}
|
||||
if gotHeader != "value" {
|
||||
t.Fatalf("X-Test header = %q, want value", gotHeader)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package compare
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "view",
|
||||
Description: "Compare two branches, tags, or commits",
|
||||
Flags: []common.Flag{
|
||||
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
|
||||
{Name: "base", Usage: "Target branch, tag, or commit", 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", comparePath(ctx, head, base), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "files",
|
||||
Description: "List changed files between two refs",
|
||||
Flags: []common.Flag{
|
||||
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
|
||||
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
|
||||
{Name: "file", Short: "f", Usage: "Filter by file path"},
|
||||
{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
|
||||
}
|
||||
head, err := ctx.RequireArg("head")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base, err := ctx.RequireArg("base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if file := ctx.Arg("file"); file != "" {
|
||||
q.Set("filepath", file)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func comparePath(ctx *common.RuntimeContext, head, base string) string {
|
||||
return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base))
|
||||
}
|
||||
|
||||
func encodeRef(ref string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(ref))
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package compare
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestCompareViewEncodesRefs(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"commits_count": 1})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCompareShortcut(t, server, "view", map[string]string{
|
||||
"head": "feature/api",
|
||||
"base": "master",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
if calledPath == "" {
|
||||
t.Fatal("server was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" {
|
||||
t.Fatalf("filepath query = %q, want cmd/api/api.go", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("page"); got != "2" {
|
||||
t.Fatalf("page query = %q, want 2", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "50" {
|
||||
t.Fatalf("limit query = %q, want 50", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCompareShortcut(t, server, "files", map[string]string{
|
||||
"head": "bugfix",
|
||||
"base": "release/v1",
|
||||
"file": "cmd/api/api.go",
|
||||
"page": "2",
|
||||
"limit": "50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("files shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRefUsesRawURLBase64(t *testing.T) {
|
||||
got := encodeRef("feature/api")
|
||||
want := "ZmVhdHVyZS9hcGk"
|
||||
if got != want {
|
||||
t.Fatalf("encodeRef() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findCompareShortcut(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 findCompareShortcut(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 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +223,48 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "List issue assigners",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if keyword := ctx.Arg("keyword"); keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authors",
|
||||
Description: "List issue authors",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if keyword := ctx.Arg("keyword"); keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,52 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) {
|
|||
assertEqual(t, updatePayload["status_id"], float64(5))
|
||||
}
|
||||
|
||||
func TestIssueAssignersShortcutWithKeyword(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
assertEqual(t, r.URL.Query().Get("keyword"), "alice")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"assigners": []map[string]interface{}{
|
||||
{"id": 7, "name": "Alice", "login": "alice"},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "assigners", map[string]string{
|
||||
"keyword": "alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("assigners shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAuthorsShortcutWithKeyword(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_authors.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
assertEqual(t, r.URL.Query().Get("keyword"), "bob")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"authors": []map[string]interface{}{
|
||||
{"id": 8, "name": "Bob", "login": "bob"},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "authors", map[string]string{
|
||||
"keyword": "bob",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authors shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findIssueShortcut(t, name)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var roleAliases = map[string]string{
|
||||
"manager": "Manager",
|
||||
"developer": "Developer",
|
||||
"reporter": "Reporter",
|
||||
"Manager": "Manager",
|
||||
"Developer": "Developer",
|
||||
"Reporter": "Reporter",
|
||||
}
|
||||
|
||||
// Shortcuts returns repository member management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository members",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add",
|
||||
Description: "Add a repository member by user ID",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := parseUserID(ctx.Arg("user-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch-add",
|
||||
Description: "Add multiple repository members by user IDs or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"},
|
||||
{Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"},
|
||||
{Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAdd,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "Remove a repository member by user ID",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := parseUserID(ctx.Arg("user-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "role",
|
||||
Description: "Change a repository member role",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := parseUserID(ctx.Arg("user-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeRole(ctx.Arg("role"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite-link",
|
||||
Description: "Get or create a repository invite link",
|
||||
Flags: []common.Flag{
|
||||
{Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"},
|
||||
{Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeInviteRole(ctx.Arg("role"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apply, err := parseBoolArg("apply", ctx.Arg("apply"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("role", role)
|
||||
query.Set("is_apply", strconv.FormatBool(apply))
|
||||
env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite-info",
|
||||
Description: "Show repository invite link information",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sign", Short: "s", Usage: "Invite link sign", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sign, err := ctx.RequireArg("sign")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("invite_sign", sign)
|
||||
env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "accept-invite",
|
||||
Description: "Accept a repository invite link",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sign", Short: "s", Usage: "Invite link sign", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sign, err := ctx.RequireArg("sign")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("invite_sign", sign)
|
||||
env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return fmt.Errorf("provide --user-ids or --from")
|
||||
}
|
||||
if parseDryRun(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"user_ids": userIDs,
|
||||
"count": len(userIDs),
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(userIDs))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
for _, userID := range userIDs {
|
||||
env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID})
|
||||
result := map[string]interface{}{"user_id": userID}
|
||||
if err != nil {
|
||||
result["ok"] = false
|
||||
result["error"] = err.Error()
|
||||
failed++
|
||||
} else {
|
||||
result["ok"] = env.OK
|
||||
result["data"] = env.Data
|
||||
if env.OK {
|
||||
succeeded++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if err := ctx.OutputData(map[string]interface{}{
|
||||
"count": len(userIDs),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"results": results,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collaboratorsPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func collaboratorsRemovePath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("%s/remove", collaboratorsPath(ctx))
|
||||
}
|
||||
|
||||
func collaboratorsRolePath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx))
|
||||
}
|
||||
|
||||
func inviteLinkPath(ctx *common.RuntimeContext, action string) string {
|
||||
return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action)
|
||||
}
|
||||
|
||||
func parseUserID(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
userID, err := strconv.Atoi(value)
|
||||
if err != nil || userID <= 0 {
|
||||
return 0, fmt.Errorf("invalid user ID %q", value)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func normalizeRole(value string) (string, error) {
|
||||
role, ok := roleAliases[strings.TrimSpace(value)]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value)
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func normalizeInviteRole(value string) (string, error) {
|
||||
role, err := normalizeRole(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value)
|
||||
}
|
||||
return strings.ToLower(role), nil
|
||||
}
|
||||
|
||||
func parseBoolArg(name, value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDryRun(value string) bool {
|
||||
ok, _ := parseBoolArg("dry-run", value)
|
||||
return ok && strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
func collectUserIDs(inline, csvPath string) ([]int, error) {
|
||||
seen := map[int]bool{}
|
||||
var ids []int
|
||||
add := func(raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
userID, err := parseUserID(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !seen[userID] {
|
||||
seen[userID] = true
|
||||
ids = append(ids, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(inline, ",") {
|
||||
if err := add(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if csvPath != "" {
|
||||
csvIDs, err := readUserIDsFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range csvIDs {
|
||||
if !seen[userID] {
|
||||
seen[userID] = true
|
||||
ids = append(ids, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func readUserIDsFromCSV(path string) ([]int, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
rows, err := csv.NewReader(file).ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
column := 0
|
||||
start := 0
|
||||
if idx := userIDColumn(rows[0]); idx >= 0 {
|
||||
column = idx
|
||||
start = 1
|
||||
}
|
||||
|
||||
var ids []int
|
||||
for _, row := range rows[start:] {
|
||||
if column >= len(row) {
|
||||
continue
|
||||
}
|
||||
userID, err := parseUserID(row[column])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, userID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func userIDColumn(header []string) int {
|
||||
for i, name := range header {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "user_id", "userid", "id":
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMemberList(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/collaborators.json")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "list", nil); err != nil {
|
||||
t.Fatalf("list shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberAdd(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/collaborators.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil {
|
||||
t.Fatalf("add shortcut failed: %v", err)
|
||||
}
|
||||
assertNumber(t, payload["user_id"], 101)
|
||||
}
|
||||
|
||||
func TestMemberBatchAdd(t *testing.T) {
|
||||
var seen []int
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/collaborators.json")
|
||||
payload := decodeJSON(t, r)
|
||||
seen = append(seen, int(payload["user_id"].(float64)))
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
csvPath := writeTempCSV(t, "user_id\n102\n103\n")
|
||||
err := runMemberShortcut(t, server, "batch-add", map[string]string{
|
||||
"user-ids": "101,102",
|
||||
"from": csvPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-add shortcut failed: %v", err)
|
||||
}
|
||||
want := []int{101, 102, 103}
|
||||
if !reflect.DeepEqual(seen, want) {
|
||||
t.Fatalf("batch-add user IDs = %v, want %v", seen, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "batch-add", map[string]string{
|
||||
"user-ids": "101,102",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-add dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/collaborators.json")
|
||||
payload := decodeJSON(t, r)
|
||||
if int(payload["user_id"].(float64)) == 102 {
|
||||
http.Error(w, "member add failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "batch-add", map[string]string{
|
||||
"user-ids": "101,102",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected batch-add to return an error when one request fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRemove(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil {
|
||||
t.Fatalf("remove shortcut failed: %v", err)
|
||||
}
|
||||
assertNumber(t, payload["user_id"], 101)
|
||||
}
|
||||
|
||||
func TestMemberRole(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "role", map[string]string{
|
||||
"user-id": "101",
|
||||
"role": "developer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("role shortcut failed: %v", err)
|
||||
}
|
||||
assertNumber(t, payload["user_id"], 101)
|
||||
if payload["role"] != "Developer" {
|
||||
t.Fatalf("role = %v, want Developer", payload["role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberInviteLink(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json")
|
||||
if r.URL.Query().Get("role") != "developer" {
|
||||
t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role"))
|
||||
}
|
||||
if r.URL.Query().Get("is_apply") != "false" {
|
||||
t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"sign": "abc"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "invite-link", map[string]string{
|
||||
"role": "developer",
|
||||
"apply": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invite-link shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberInviteInfo(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json")
|
||||
if r.URL.Query().Get("invite_sign") != "abc" {
|
||||
t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"sign": "abc"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil {
|
||||
t.Fatalf("invite-info shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberAcceptInvite(t *testing.T) {
|
||||
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json")
|
||||
if r.URL.Query().Get("invite_sign") != "abc" {
|
||||
t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil {
|
||||
t.Fatalf("accept-invite shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUserIDs(t *testing.T) {
|
||||
csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n")
|
||||
got, err := collectUserIDs("101,102", csvPath)
|
||||
if err != nil {
|
||||
t.Fatalf("collectUserIDs returned error: %v", err)
|
||||
}
|
||||
want := []int{101, 102, 103}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("collectUserIDs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRoleRejectsInvalidRole(t *testing.T) {
|
||||
if _, err := normalizeRole("owner"); err == nil {
|
||||
t.Fatal("expected invalid role to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findMemberShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findMemberShortcut(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 newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNumber(t *testing.T, got interface{}, want int) {
|
||||
t.Helper()
|
||||
value, ok := got.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("got %v (%T), want JSON number", got, got)
|
||||
}
|
||||
if int(value) != want {
|
||||
t.Fatalf("got %v, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTempCSV(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "members.csv")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp csv: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List milestones",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "category", Short: "c", Usage: "Filter by category: opening, closed"},
|
||||
{Name: "only-name", Usage: "Return only milestone id and name: true or false"},
|
||||
{Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
{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"))
|
||||
setQueryIfPresent(q, "keyword", ctx.Arg("keyword"))
|
||||
setQueryIfPresent(q, "category", ctx.Arg("category"))
|
||||
setQueryIfPresent(q, "only_name", ctx.Arg("only-name"))
|
||||
setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by"))
|
||||
setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Milestone description", Required: true},
|
||||
{Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := milestonePayload(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", milestonePath(ctx), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View milestone details and linked issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"},
|
||||
{Name: "author-id", Usage: "Filter issues by author ID"},
|
||||
{Name: "assigner-id", Usage: "Filter issues by assignee ID"},
|
||||
{Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"},
|
||||
{Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
{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
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
setQueryIfPresent(q, "category", ctx.Arg("category"))
|
||||
setQueryIfPresent(q, "author_id", ctx.Arg("author-id"))
|
||||
setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id"))
|
||||
setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids")))
|
||||
setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by"))
|
||||
setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Milestone name"},
|
||||
{Name: "description", Short: "d", Usage: "Milestone description"},
|
||||
{Name: "due-date", Usage: "Due date in YYYY-MM-DD format"},
|
||||
},
|
||||
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, err := milestonePayload(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a milestone",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
newStatusShortcut("close", "Close a milestone", "closed"),
|
||||
newStatusShortcut("reopen", "Reopen a milestone", "open"),
|
||||
}
|
||||
}
|
||||
|
||||
func milestonePath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func milestoneItemPath(ctx *common.RuntimeContext, id string) string {
|
||||
return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id))
|
||||
}
|
||||
|
||||
func milestoneStatusPath(ctx *common.RuntimeContext, id string) string {
|
||||
return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id))
|
||||
}
|
||||
|
||||
func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) {
|
||||
payload := map[string]interface{}{}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if description := ctx.Arg("description"); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
if dueDate := ctx.Arg("due-date"); dueDate != "" {
|
||||
payload["effective_date"] = dueDate
|
||||
}
|
||||
|
||||
if requireAll {
|
||||
for _, name := range []string{"name", "description", "due-date"} {
|
||||
if _, err := ctx.RequireArg(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func newStatusShortcut(name, description, status string) *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: name,
|
||||
Description: description,
|
||||
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("POST", milestoneStatusPath(ctx, id), map[string]interface{}{
|
||||
"status": status,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setQueryIfPresent(q url.Values, name, value string) {
|
||||
if value != "" {
|
||||
q.Set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCSV(value string) string {
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, ",")
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package milestone
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestMilestoneList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json")
|
||||
assertEqual(t, r.URL.Query().Get("category"), "opening")
|
||||
assertEqual(t, r.URL.Query().Get("keyword"), "v1")
|
||||
assertEqual(t, r.URL.Query().Get("page"), "2")
|
||||
assertEqual(t, r.URL.Query().Get("limit"), "50")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "list", map[string]string{
|
||||
"category": "opening",
|
||||
"keyword": "v1",
|
||||
"page": "2",
|
||||
"limit": "50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneCreatePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "create", map[string]string{
|
||||
"name": "v1.0",
|
||||
"description": "first release",
|
||||
"due-date": "2026-07-01",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["name"], "v1.0")
|
||||
assertEqual(t, payload["description"], "first release")
|
||||
assertEqual(t, payload["effective_date"], "2026-07-01")
|
||||
}
|
||||
|
||||
func TestMilestoneViewWithIssueFilters(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json")
|
||||
assertEqual(t, r.URL.Query().Get("category"), "opened")
|
||||
assertEqual(t, r.URL.Query().Get("author_id"), "11")
|
||||
assertEqual(t, r.URL.Query().Get("assigner_id"), "22")
|
||||
assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3")
|
||||
writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "view", map[string]string{
|
||||
"id": "7",
|
||||
"category": "opened",
|
||||
"author-id": "11",
|
||||
"assigner-id": "22",
|
||||
"issue-tag-ids": "1, 2,3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneUpdatePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "update", map[string]string{
|
||||
"id": "7",
|
||||
"due-date": "2026-08-01",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := payload["name"]; ok {
|
||||
t.Fatal("update payload should omit empty name")
|
||||
}
|
||||
assertEqual(t, payload["effective_date"], "2026-08-01")
|
||||
}
|
||||
|
||||
func TestMilestoneUpdateRequiresChange(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"})
|
||||
if err == nil {
|
||||
t.Fatal("expected update without fields to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneCloseAndReopen(t *testing.T) {
|
||||
gotStatuses := []string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json")
|
||||
payload := decodeJSON(t, r)
|
||||
gotStatuses = append(gotStatuses, payload["status"].(string))
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("close shortcut failed: %v", err)
|
||||
}
|
||||
if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("reopen shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, gotStatuses[0], "closed")
|
||||
assertEqual(t, gotStatuses[1], "open")
|
||||
}
|
||||
|
||||
func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findMilestoneShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findMilestoneShortcut(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 assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package pr
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -84,6 +85,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enrichPullRequestClosedAt(ctx, env); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -131,6 +135,27 @@ func Shortcuts() []*common.Shortcut {
|
|||
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: "files",
|
||||
Description: "List changed files in a pull request",
|
||||
|
|
@ -370,3 +395,93 @@ func extractIssueID(env *output.Envelope) (int64, error) {
|
|||
}
|
||||
return int64(idFloat), nil
|
||||
}
|
||||
|
||||
func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pr, ok := data["pull_request"].(map[string]interface{})
|
||||
if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" {
|
||||
return nil
|
||||
}
|
||||
issue, ok := data["issue"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
issueID, ok := numberField(issue, "id")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closedAt := extractPullRequestClosedAt(journalsEnv)
|
||||
if closedAt == "" {
|
||||
return nil
|
||||
}
|
||||
pr["closed_at"] = closedAt
|
||||
data["closed_at"] = closedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func isClosedPullRequest(pr map[string]interface{}) bool {
|
||||
if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" {
|
||||
return true
|
||||
}
|
||||
status, ok := numberField(pr, "status")
|
||||
return ok && int(status) == 2
|
||||
}
|
||||
|
||||
func extractPullRequestClosedAt(env *output.Envelope) string {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawJournals, ok := data["journals"].([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for i := len(rawJournals) - 1; i >= 0; i-- {
|
||||
journal, ok := rawJournals[i].(map[string]interface{})
|
||||
if !ok || stringField(journal, "operate_category") != "status" {
|
||||
continue
|
||||
}
|
||||
content := stringField(journal, "operate_content")
|
||||
if !isPullRequestCloseOperation(content) {
|
||||
continue
|
||||
}
|
||||
if updatedAt := stringField(journal, "updated_at"); updatedAt != "" {
|
||||
return updatedAt
|
||||
}
|
||||
if createdAt := stringField(journal, "created_at"); createdAt != "" {
|
||||
return createdAt
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPullRequestCloseOperation(content string) bool {
|
||||
content = strings.ToLower(content)
|
||||
return strings.Contains(content, "合并请求") &&
|
||||
(strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed"))
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func numberField(m map[string]interface{}, key string) (float64, bool) {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -53,6 +54,87 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) {
|
|||
assertEqual(t, journalPayload["notes"], "LGTM, looks good!")
|
||||
}
|
||||
|
||||
func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) {
|
||||
var issueJournalCalled bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142756),
|
||||
},
|
||||
"pull_request": map[string]interface{}{
|
||||
"status": float64(2),
|
||||
"pull_request_staus": "closed",
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json":
|
||||
issueJournalCalled = true
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []map[string]interface{}{
|
||||
{
|
||||
"operate_category": "pull_request",
|
||||
"operate_content": "创建了<b>合并请求</b>",
|
||||
"created_at": "2026-05-24 21:43",
|
||||
},
|
||||
{
|
||||
"operate_category": "status",
|
||||
"operate_content": "<b>拒绝了</b>合并请求",
|
||||
"created_at": "2026-05-25 08:58",
|
||||
"updated_at": "2026-05-25 08:58",
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{
|
||||
"id": "37",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
if !issueJournalCalled {
|
||||
t.Fatal("issue journal endpoint was not called")
|
||||
}
|
||||
data := env.Data.(map[string]interface{})
|
||||
assertEqual(t, data["closed_at"], "2026-05-25 08:58")
|
||||
prData := data["pull_request"].(map[string]interface{})
|
||||
assertEqual(t, prData["closed_at"], "2026-05-25 08:58")
|
||||
}
|
||||
|
||||
func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142793),
|
||||
},
|
||||
"pull_request": map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"pull_request_staus": "open",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{
|
||||
"id": "45",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
data := env.Data.(map[string]interface{})
|
||||
if _, ok := data["closed_at"]; ok {
|
||||
t.Fatal("open PR should not include closed_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommentFailsWhenPRNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
|
@ -263,20 +345,65 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPRReopenUsesV1Endpoint(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{}{
|
||||
"status": 0,
|
||||
"message": "success",
|
||||
})
|
||||
}))
|
||||
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 runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
_, err := runPRShortcutWithOutput(t, server, name, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) {
|
||||
t.Helper()
|
||||
shortcut := findPRShortcut(t, name)
|
||||
client := &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Client: client,
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
err := shortcut.Run(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name != "view" {
|
||||
return nil, nil
|
||||
}
|
||||
id := args["id"]
|
||||
env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enrichPullRequestClosedAt(ctx, env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func findPRShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
||||
|
|
@ -21,28 +24,34 @@ import (
|
|||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"compare": compare.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"issue": "Issue operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"webhook": "Webhook operations",
|
||||
"workflow": "AI agent workflow analysis",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "Show repository README content",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
{Name: "path", Usage: "README directory path"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
if path := ctx.Arg("path"); path != "" {
|
||||
q.Set("filepath", path)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new repository",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
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"
|
||||
)
|
||||
|
||||
func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("ref"); got != "main" {
|
||||
t.Fatalf("ref query = %q, want main", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("filepath"); got != "docs" {
|
||||
t.Fatalf("filepath query = %q, want docs", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"type": "file",
|
||||
"name": "README.md",
|
||||
"content": "# docs\n",
|
||||
}); err != nil {
|
||||
t.Fatalf("write response: %v", err)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(server, "readme", map[string]string{
|
||||
"ref": "main",
|
||||
"path": "docs",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("readme shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name != name {
|
||||
continue
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
return fmt.Errorf("shortcut %q not found", name)
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ skills/
|
|||
│ ├── REFERENCE.md # PR API 参考
|
||||
│ └── examples/
|
||||
│ └── pr-workflow.md # PR 工作流
|
||||
├── gitlink-member/ # 仓库成员管理
|
||||
│ └── SKILL.md # 成员与邀请链接操作指南
|
||||
├── gitlink-branch/ # 分支管理
|
||||
│ ├── SKILL.md # 分支操作指南
|
||||
│ └── examples/
|
||||
|
|
@ -124,6 +126,7 @@ skills/
|
|||
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
|
||||
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
|
||||
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
|
||||
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |
|
||||
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
|
||||
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: gitlink-compare
|
||||
version: 1.0.0
|
||||
description: "Compare GitLink branches, tags, or commits and inspect changed files."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli compare --help"
|
||||
---
|
||||
|
||||
# gitlink-compare
|
||||
|
||||
Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Description |
|
||||
|----------|-------------|
|
||||
| `compare +view` | Compare two branches, tags, or commits |
|
||||
| `compare +files` | List changed files between two refs |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Compare two refs and include commit/diff summary
|
||||
gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/api --base master
|
||||
|
||||
# List changed files
|
||||
gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/api --base master
|
||||
|
||||
# Filter a single file in the file diff endpoint
|
||||
gitlink-cli compare +files --owner Gitlink --repo forgeplus \
|
||||
--head feature/api --base master --file cmd/api/api.go
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Pass normal branch, tag, or commit names. The CLI base64-url encodes refs before calling GitLink compare endpoints.
|
||||
- `compare +view` calls `/api/{owner}/{repo}/compare/{head}...{base}`.
|
||||
- `compare +files` calls `/api/v1/{owner}/{repo}/compare/{head}...{base}/files`.
|
||||
|
|
@ -27,6 +27,8 @@ metadata:
|
|||
| `issue +close` | 关闭 Issue | 是 |
|
||||
| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||
| `issue +comment` | 添加评论 | 是 |
|
||||
| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) |
|
||||
| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -54,6 +56,12 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv
|
|||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --number 4 --body "已修复,请验证"
|
||||
|
||||
# 查询 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice
|
||||
|
||||
# 查询 Issue 发布人
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob
|
||||
```
|
||||
|
||||
## Raw API 补充
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
# Issue Assigners
|
||||
|
||||
Use `issue +assigners` to list users that can be assigned to Issues in a repository.
|
||||
This helps users find the assignee ID before creating or updating an Issue.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--owner` | Repository owner |
|
||||
| `--repo` | Repository name |
|
||||
| `--keyword`, `-k` | Optional search keyword |
|
||||
|
||||
## API
|
||||
|
||||
```http
|
||||
GET /api/v1/{owner}/{repo}/issue_assigners.json
|
||||
```
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Issue Authors
|
||||
|
||||
Use `issue +authors` to list users who have authored Issues in a repository.
|
||||
This helps users find author IDs for Issue filtering and reporting workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--owner` | Repository owner |
|
||||
| `--repo` | Repository name |
|
||||
| `--keyword`, `-k` | Optional search keyword |
|
||||
|
||||
## API
|
||||
|
||||
```http
|
||||
GET /api/v1/{owner}/{repo}/issue_authors.json
|
||||
```
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
name: gitlink-member
|
||||
description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。"
|
||||
metadata:
|
||||
cliHelp: "gitlink-cli member --help"
|
||||
---
|
||||
|
||||
# gitlink-member(仓库成员管理)
|
||||
|
||||
当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时使用本 Skill。
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `member +list` | 列出仓库成员 |
|
||||
| `member +add` | 通过用户 ID 添加仓库成员 |
|
||||
| `member +batch-add` | 通过用户 ID 列表或 CSV 批量添加成员 |
|
||||
| `member +remove` | 通过用户 ID 移除仓库成员 |
|
||||
| `member +role` | 修改成员角色 |
|
||||
| `member +invite-link` | 获取或生成当前邀请链接 |
|
||||
| `member +invite-info` | 查看邀请链接信息 |
|
||||
| `member +accept-invite` | 接受邀请链接 |
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 列出仓库成员
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加成员
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# 批量添加前预览
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# 从 CSV 批量添加。CSV 支持 user_id、userid、id 列;无表头时读取第一列。
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# 修改角色。角色支持 Manager、Developer、Reporter,也支持小写别名。
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# 获取或生成当前邀请链接。role 支持 manager、developer、reporter;apply 表示是否需要审核。
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
|
||||
# 查看邀请链接信息
|
||||
gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign <invite_sign>
|
||||
|
||||
# 接受邀请链接
|
||||
gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign <invite_sign>
|
||||
```
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。
|
||||
- 批量添加前优先使用 `--dry-run` 预览。
|
||||
- 避免在公开日志中暴露邀请链接的完整 `sign`。
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
name: gitlink-milestone
|
||||
version: 1.0.0
|
||||
description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli milestone --help"
|
||||
---
|
||||
|
||||
# gitlink-milestone
|
||||
|
||||
**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior.
|
||||
**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`.
|
||||
**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Description | Operation |
|
||||
|----------|-------------|-----------|
|
||||
| `milestone +list` | List repository milestones | Read |
|
||||
| `milestone +create` | Create a milestone | Write |
|
||||
| `milestone +view` | View milestone details and linked issues | Read |
|
||||
| `milestone +update` | Update milestone fields | Write |
|
||||
| `milestone +delete` | Delete a milestone | Destructive |
|
||||
| `milestone +close` | Close a milestone | Write |
|
||||
| `milestone +reopen` | Reopen a closed milestone | Write |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# List open milestones
|
||||
gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening
|
||||
|
||||
# Create a milestone
|
||||
gitlink-cli milestone +create --owner Gitlink --repo forgeplus \
|
||||
--name v1.0 --description "First stable release" --due-date 2026-07-01
|
||||
|
||||
# View milestone details and linked opened issues
|
||||
gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened
|
||||
|
||||
# Update the due date
|
||||
gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01
|
||||
|
||||
# Close and reopen
|
||||
gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Command | Key parameters |
|
||||
|---------|----------------|
|
||||
| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` |
|
||||
| `+create` | `--name`, `--description`, `--due-date` |
|
||||
| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` |
|
||||
| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` |
|
||||
| `+delete` | `--id` |
|
||||
| `+close` / `+reopen` | `--id` |
|
||||
|
||||
## API Notes
|
||||
|
||||
- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`.
|
||||
- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`.
|
||||
- `--due-date` maps to the GitLink API field `effective_date`.
|
||||
- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API.
|
||||
|
|
@ -24,6 +24,7 @@ metadata:
|
|||
| `pr +view` | PR 详情 | 否(公开项目) |
|
||||
| `pr +merge` | 合并 PR | 是 |
|
||||
| `pr +close` | 关闭 PR | 是 |
|
||||
| `pr +reopen` | 重开已关闭的 PR | 是 |
|
||||
| `pr +files` | 变更文件列表 | 否 |
|
||||
| `pr +diff` | 查看变更文件和 diff 内容 | 否 |
|
||||
| `pr +versions` | 查看 PR patchset/version 列表 | 否 |
|
||||
|
|
@ -51,6 +52,9 @@ gitlink-cli pr +merge --id 3 --method squash
|
|||
# 关闭 PR(拒绝合并)
|
||||
gitlink-cli pr +close --id 3
|
||||
|
||||
# 重开已关闭的 PR
|
||||
gitlink-cli pr +reopen --id 3
|
||||
|
||||
# 查看变更文件(含 diff 内容)
|
||||
gitlink-cli pr +files --id 3
|
||||
|
||||
|
|
@ -157,7 +161,7 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff
|
|||
- GitLink 的默认分支通常是 `master`(非 `main`),创建 PR 时注意 `--base` 参数
|
||||
- 合并 PR 前建议先用 `pr +view` 确认状态
|
||||
- **PR 创建要求源分支与目标分支有实际代码差异**,否则返回"分支内容相同,无需创建合并请求"
|
||||
- PR 查看/合并/关闭需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回)
|
||||
- PR 查看/合并/关闭/重开需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回)
|
||||
- `pr +merge` 默认使用 merge 方式,可通过 `--method` 指定 rebase 或 squash
|
||||
- `pr +diff` 实际调用 `/pulls/:id/files` 端点,返回变更文件列表和 diff 内容
|
||||
- `pr +versions` / `pr +version-diff` 使用 v1 API,`--id` 为网页 URL `/pulls/N` 中的 PR 序号,`--version-id` 为 patchset/version id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# pr +reopen
|
||||
|
||||
Reopen a closed Pull Request.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +reopen --id 3
|
||||
gitlink-cli pr +reopen -i 3
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--id` / `-i` | Yes | PR number from the web URL `/pulls/N` |
|
||||
| `--owner` | No | Repository owner, auto-detected from git remote when omitted |
|
||||
| `--repo` | No | Repository name, auto-detected from git remote when omitted |
|
||||
| `--format` | No | Output format: `json`, `table`, or `yaml` |
|
||||
|
||||
## API
|
||||
|
||||
```text
|
||||
POST /v1/{owner}/{repo}/pulls/{number}/reopen
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `pr +view -i <id>` first to confirm the PR is currently closed.
|
||||
- This command uses the PR number shown in the web URL, not the internal database ID.
|
||||
Loading…
Reference in New Issue