diff --git a/README.md b/README.md
index 670808c..6711900 100644
--- a/README.md
+++ b/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
diff --git a/README.zh-CN.md b/README.zh-CN.md
index f426948..1a56fe4 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -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
diff --git a/cmd/api/api.go b/cmd/api/api.go
index af0593d..9dd7ad1 100644
--- a/cmd/api/api.go
+++ b/cmd/api/api.go
@@ -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 == "" {
diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go
new file mode 100644
index 0000000..821dfcb
--- /dev/null
+++ b/cmd/api/api_test.go
@@ -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")
+ }
+}
diff --git a/doc/changes/member-shortcut.md b/doc/changes/member-shortcut.md
new file mode 100644
index 0000000..b3278b0
--- /dev/null
+++ b/doc/changes/member-shortcut.md
@@ -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 说明。
diff --git a/doc/changes/milestone-shortcut.md b/doc/changes/milestone-shortcut.md
new file mode 100644
index 0000000..a0e569b
--- /dev/null
+++ b/doc/changes/milestone-shortcut.md
@@ -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。
diff --git a/examples/workflows/community-ops-automation/.gitignore b/examples/workflows/community-ops-automation/.gitignore
new file mode 100644
index 0000000..7d2b3d8
--- /dev/null
+++ b/examples/workflows/community-ops-automation/.gitignore
@@ -0,0 +1,8 @@
+outputs/
+__pycache__/
+*.pyc
+.pytest_cache/
+.mypy_cache/
+*.log
+*.tmp
+*.swp
diff --git a/examples/workflows/community-ops-automation/LICENSE b/examples/workflows/community-ops-automation/LICENSE
new file mode 100644
index 0000000..d1ce275
--- /dev/null
+++ b/examples/workflows/community-ops-automation/LICENSE
@@ -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.
diff --git a/examples/workflows/community-ops-automation/README.md b/examples/workflows/community-ops-automation/README.md
new file mode 100644
index 0000000..181e5cd
--- /dev/null
+++ b/examples/workflows/community-ops-automation/README.md
@@ -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 项目上复现
+- 提供运行脚本、验证记录、示例产物和单元测试
diff --git a/examples/workflows/community-ops-automation/docs/architecture.md b/examples/workflows/community-ops-automation/docs/architecture.md
new file mode 100644
index 0000000..a6264ed
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/architecture.md
@@ -0,0 +1,26 @@
+# 架构说明
+
+本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
+
+
+
+## 设计目标
+
+- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
+- 可复现:同一配置可重复跑出同类报告
+- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
+- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
+
+## 为什么选这个链路
+
+子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
+
+1. 仓库信息采集
+2. Issue 列表采集
+3. PR 列表采集
+4. Release 列表采集
+5. 报告生成
+6. Issue 摘要发布
+
+该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。
+
diff --git a/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg
new file mode 100644
index 0000000..8cf5f29
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/examples/workflows/community-ops-automation/docs/demo-output.md b/examples/workflows/community-ops-automation/docs/demo-output.md
new file mode 100644
index 0000000..c977a30
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/demo-output.md
@@ -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 的发布验证。
diff --git a/examples/workflows/community-ops-automation/docs/quickstart.md b/examples/workflows/community-ops-automation/docs/quickstart.md
new file mode 100644
index 0000000..07d9a43
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/quickstart.md
@@ -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
diff --git a/examples/workflows/community-ops-automation/docs/runbook.md b/examples/workflows/community-ops-automation/docs/runbook.md
new file mode 100644
index 0000000..86087a1
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/runbook.md
@@ -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 回写能力。
diff --git a/examples/workflows/community-ops-automation/docs/submission-checklist.md b/examples/workflows/community-ops-automation/docs/submission-checklist.md
new file mode 100644
index 0000000..9041b70
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/submission-checklist.md
@@ -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/` 提供固定示例产物,便于快速查看报告格式和输出内容。
diff --git a/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md
new file mode 100644
index 0000000..9be839b
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md
@@ -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/`。
diff --git a/examples/workflows/community-ops-automation/docs/verification.md b/examples/workflows/community-ops-automation/docs/verification.md
new file mode 100644
index 0000000..38613c2
--- /dev/null
+++ b/examples/workflows/community-ops-automation/docs/verification.md
@@ -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
+```
diff --git a/examples/workflows/community-ops-automation/examples/demo_active_config.json b/examples/workflows/community-ops-automation/examples/demo_active_config.json
new file mode 100644
index 0000000..83074b7
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_active_config.json
@@ -0,0 +1,6 @@
+{
+ "owner": "Gitlink",
+ "repo": "gitlink-cli",
+ "window_days": 7,
+ "output_dir": "outputs"
+}
diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md
new file mode 100644
index 0000000..25e0f17
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md
@@ -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,建议优先清理。
diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md
new file mode 100644
index 0000000..a357731
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md
@@ -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,建议优先清理。
diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/README.md b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md
new file mode 100644
index 0000000..27884af
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md
@@ -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`。
diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md
new file mode 100644
index 0000000..d10b9af
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md
@@ -0,0 +1,14 @@
+# gitlink-cli Release Notes 草稿
+
+- 统计窗口:近 7 天
+- 生成时间:2026-05-20 14:32:24 UTC
+
+## 变更概览
+- 已合并 PR:0 个
+- 最近窗口内合并 PR:0 个
+
+## 变更分类
+- 无
+
+## 发布说明
+- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md
new file mode 100644
index 0000000..3dbbce4
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md
@@ -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 权限。
diff --git a/examples/workflows/community-ops-automation/examples/sample_config.json b/examples/workflows/community-ops-automation/examples/sample_config.json
new file mode 100644
index 0000000..eaf94d4
--- /dev/null
+++ b/examples/workflows/community-ops-automation/examples/sample_config.json
@@ -0,0 +1,6 @@
+{
+ "owner": "puygob236",
+ "repo": "gitlink-cli",
+ "window_days": 7,
+ "output_dir": "outputs"
+}
diff --git a/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py
new file mode 100644
index 0000000..d27be60
--- /dev/null
+++ b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py
@@ -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())
diff --git a/examples/workflows/community-ops-automation/scripts/run_demo.ps1 b/examples/workflows/community-ops-automation/scripts/run_demo.ps1
new file mode 100644
index 0000000..5ad872b
--- /dev/null
+++ b/examples/workflows/community-ops-automation/scripts/run_demo.ps1
@@ -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
diff --git a/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py
new file mode 100644
index 0000000..8025bf8
--- /dev/null
+++ b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py
@@ -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()
diff --git a/internal/client/client.go b/internal/client/client.go
index d147d96..52aab17 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -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)
}
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
new file mode 100644
index 0000000..2dcc851
--- /dev/null
+++ b/internal/client/client_test.go
@@ -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)
+ }
+}
diff --git a/shortcuts/compare/compare.go b/shortcuts/compare/compare.go
new file mode 100644
index 0000000..b39937a
--- /dev/null
+++ b/shortcuts/compare/compare.go
@@ -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))
+}
diff --git a/shortcuts/compare/compare_test.go b/shortcuts/compare/compare_test.go
new file mode 100644
index 0000000..d10b9f5
--- /dev/null
+++ b/shortcuts/compare/compare_test.go
@@ -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)
+ }
+}
diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go
index 902fda8..7c0bc68 100644
--- a/shortcuts/issue/issue.go
+++ b/shortcuts/issue/issue.go
@@ -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)
+ },
+ },
}
}
diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go
index 088f82c..661a4de 100644
--- a/shortcuts/issue/issue_test.go
+++ b/shortcuts/issue/issue_test.go
@@ -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)
diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go
new file mode 100644
index 0000000..3734da3
--- /dev/null
+++ b/shortcuts/member/member.go
@@ -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
+}
diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go
new file mode 100644
index 0000000..d379e82
--- /dev/null
+++ b/shortcuts/member/member_test.go
@@ -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
+}
diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go
new file mode 100644
index 0000000..1ec2b30
--- /dev/null
+++ b/shortcuts/milestone/milestone.go
@@ -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, ",")
+}
diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go
new file mode 100644
index 0000000..bf76193
--- /dev/null
+++ b/shortcuts/milestone/milestone_test.go
@@ -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)
+ }
+}
diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go
index 7f9984c..5fd983b 100644
--- a/shortcuts/pr/pr.go
+++ b/shortcuts/pr/pr.go
@@ -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
+ }
+}
diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go
index db58cb6..530fc9a 100644
--- a/shortcuts/pr/pr_test.go
+++ b/shortcuts/pr/pr_test.go
@@ -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": "创建了合并请求",
+ "created_at": "2026-05-24 21:43",
+ },
+ {
+ "operate_category": "status",
+ "operate_content": "拒绝了合并请求",
+ "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 {
diff --git a/shortcuts/register.go b/shortcuts/register.go
index 943b6dd..49b20ed 100644
--- a/shortcuts/register.go
+++ b/shortcuts/register.go
@@ -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",
}
diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go
index 75091a4..4d621ad 100644
--- a/shortcuts/repo/repo.go
+++ b/shortcuts/repo/repo.go
@@ -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",
diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go
new file mode 100644
index 0000000..41305c4
--- /dev/null
+++ b/shortcuts/repo/repo_test.go
@@ -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)
+}
diff --git a/skills/README.md b/skills/README.md
index 972254b..b4c6998 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -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` |
diff --git a/skills/gitlink-compare/SKILL.md b/skills/gitlink-compare/SKILL.md
new file mode 100644
index 0000000..af7dbe9
--- /dev/null
+++ b/skills/gitlink-compare/SKILL.md
@@ -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`.
diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md
index 1938da6..0928362 100644
--- a/skills/gitlink-issue/SKILL.md
+++ b/skills/gitlink-issue/SKILL.md
@@ -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 补充
diff --git a/skills/gitlink-issue/references/gitlink-issue-assigners.md b/skills/gitlink-issue/references/gitlink-issue-assigners.md
new file mode 100644
index 0000000..23f1a4e
--- /dev/null
+++ b/skills/gitlink-issue/references/gitlink-issue-assigners.md
@@ -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
+```
diff --git a/skills/gitlink-issue/references/gitlink-issue-authors.md b/skills/gitlink-issue/references/gitlink-issue-authors.md
new file mode 100644
index 0000000..49edc9f
--- /dev/null
+++ b/skills/gitlink-issue/references/gitlink-issue-authors.md
@@ -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
+```
diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md
new file mode 100644
index 0000000..61f8c50
--- /dev/null
+++ b/skills/gitlink-member/SKILL.md
@@ -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
+
+# 接受邀请链接
+gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign
+```
+
+## 安全规则
+
+- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。
+- 批量添加前优先使用 `--dry-run` 预览。
+- 避免在公开日志中暴露邀请链接的完整 `sign`。
diff --git a/skills/gitlink-milestone/SKILL.md b/skills/gitlink-milestone/SKILL.md
new file mode 100644
index 0000000..cd77948
--- /dev/null
+++ b/skills/gitlink-milestone/SKILL.md
@@ -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.
diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md
index 1fb6beb..77e0cec 100644
--- a/skills/gitlink-pr/SKILL.md
+++ b/skills/gitlink-pr/SKILL.md
@@ -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
diff --git a/skills/gitlink-pr/references/gitlink-pr-reopen.md b/skills/gitlink-pr/references/gitlink-pr-reopen.md
new file mode 100644
index 0000000..6c2505e
--- /dev/null
+++ b/skills/gitlink-pr/references/gitlink-pr-reopen.md
@@ -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 ` first to confirm the PR is currently closed.
+- This command uses the PR number shown in the web URL, not the internal database ID.