feat(notification): add OpenAPI shortcuts
This commit is contained in:
parent
52b7093846
commit
fcf703d7e3
29
README.md
29
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, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and 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, notifications, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
|
||||
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
## Why gitlink-cli?
|
||||
|
||||
- **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, Pipeline, Org, Search, and User workflows are covered by high-level commands
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Notification, Member, Branch, Release, CI, Pipeline, 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`
|
||||
|
|
@ -106,6 +106,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 🔔 Notification | List messages, mark read, delete, create @me notifications, manage message settings |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 👤 User | View user profiles and info |
|
||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
||||
|
|
@ -477,6 +478,27 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Notification Management
|
||||
|
||||
```bash
|
||||
# List unread @me messages
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# Preview marking messages as read
|
||||
gitlink-cli notification +mark-read --user zhangsan --ids 101,102 --dry-run
|
||||
|
||||
# Delete messages by IDs
|
||||
gitlink-cli notification +delete --user zhangsan --ids 101,102 --type notification
|
||||
|
||||
# Create @me notifications attached to an issue, PR, or journal
|
||||
gitlink-cli notification +create-atme --user zhangsan --receivers lisi,wangwu --atmeable-type Issue --atmeable-id 99 --dry-run
|
||||
|
||||
# Inspect and update message settings while preserving unspecified keys
|
||||
gitlink-cli notification +platform-settings
|
||||
gitlink-cli notification +settings --user zhangsan
|
||||
gitlink-cli notification +settings-update --user zhangsan --notification Normal::Project=true --email Normal::Project=false --dry-run
|
||||
```
|
||||
|
||||
### Workflow Agent Commands
|
||||
|
||||
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
|
||||
|
|
@ -648,6 +670,7 @@ See [skills/README.md](skills/README.md) for details.
|
|||
| `gitlink-search` | Search (repositories, users, etc.) |
|
||||
| `gitlink-org` | Organization management (members, teams, etc.) |
|
||||
| `gitlink-user` | User management (profile info, etc.) |
|
||||
| `gitlink-notification` | Notification/message operations and settings |
|
||||
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
||||
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
|
||||
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
|
||||
|
|
@ -681,6 +704,7 @@ gitlink-cli/
|
|||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # Search shortcuts
|
||||
│ ├── user/ # User shortcuts
|
||||
│ ├── notification/ # Notification shortcuts
|
||||
│ └── register.go # Registration entry point
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills guide
|
||||
|
|
@ -688,6 +712,7 @@ gitlink-cli/
|
|||
│ ├── gitlink-repo/ # Repository skill
|
||||
│ ├── gitlink-issue/ # Issue skill
|
||||
│ ├── gitlink-pr/ # PR skill
|
||||
│ ├── gitlink-notification/ # Notification skill
|
||||
│ ├── gitlink-pm/ # Project management skill
|
||||
│ └── ...
|
||||
├── doc/ # Design documents
|
||||
|
|
|
|||
|
|
@ -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+ 命令和 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)**
|
||||
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、通知消息、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
|
|
@ -105,6 +105,7 @@
|
|||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 🔔 通知消息 | 查看消息、标记已读、删除消息、创建 @我通知、管理消息设置 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
|
|
@ -455,6 +456,27 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### 通知消息管理
|
||||
|
||||
```bash
|
||||
# 查看未读 @我消息
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# 预览标记已读,不修改线上数据
|
||||
gitlink-cli notification +mark-read --user zhangsan --ids 101,102 --dry-run
|
||||
|
||||
# 按 ID 删除消息
|
||||
gitlink-cli notification +delete --user zhangsan --ids 101,102 --type notification
|
||||
|
||||
# 创建绑定到 Issue、PR 或 Journal 的 @我通知
|
||||
gitlink-cli notification +create-atme --user zhangsan --receivers lisi,wangwu --atmeable-type Issue --atmeable-id 99 --dry-run
|
||||
|
||||
# 查看并更新消息设置,未指定的配置会保留原值
|
||||
gitlink-cli notification +platform-settings
|
||||
gitlink-cli notification +settings --user zhangsan
|
||||
gitlink-cli notification +settings-update --user zhangsan --notification Normal::Project=true --email Normal::Project=false --dry-run
|
||||
```
|
||||
|
||||
### Raw API
|
||||
|
||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
||||
|
|
@ -526,6 +548,7 @@ git push gitlink
|
|||
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-notification` | 通知消息操作与设置管理 |
|
||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||
| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) |
|
||||
|
||||
|
|
@ -558,6 +581,7 @@ gitlink-cli/
|
|||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ ├── notification/ # 通知消息 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills 使用指南
|
||||
|
|
@ -565,6 +589,7 @@ gitlink-cli/
|
|||
│ ├── gitlink-repo/ # 仓库 Skill
|
||||
│ ├── gitlink-issue/ # Issue Skill
|
||||
│ ├── gitlink-pr/ # PR Skill
|
||||
│ ├── gitlink-notification/ # 通知消息 Skill
|
||||
│ ├── gitlink-pm/ # 项目管理 Skill
|
||||
│ └── ...
|
||||
├── doc/ # 设计文档
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Notification Shortcut
|
||||
|
||||
新增 `notification` Shortcut 组,补齐 GitLink OpenAPI 中用户消息与消息设置相关接口的高层封装:
|
||||
|
||||
- `notification +list`:查看用户消息列表,支持 `notification` / `atme` 与已读状态过滤。
|
||||
- `notification +mark-read`:按消息 ID 标记已读,支持 `--all-unread`。
|
||||
- `notification +delete`:按消息 ID 删除消息。
|
||||
- `notification +create-atme`:基于 Issue、PullRequest 或 Journal 创建 @我通知。
|
||||
- `notification +platform-settings`:查看平台消息设置模板。
|
||||
- `notification +settings`:查看用户消息设置。
|
||||
- `notification +settings-update`:更新用户消息/邮件设置。
|
||||
|
||||
实现要点:
|
||||
|
||||
- 写操作均支持 `--dry-run`,可先输出 method/path/body 供用户或 Agent 确认。
|
||||
- `settings-update` 会先读取当前用户设置,再合并 CLI 指定的 key,避免未指定配置被覆盖。
|
||||
- 参数校验覆盖消息类型、已读状态、@我对象类型、消息 ID、布尔配置项等常见误用场景。
|
||||
- 补充单元测试覆盖 HTTP method/path/query/payload、dry-run 不触发 API、设置合并保留原值等场景。
|
||||
- README / README.zh-CN / Skills 文档同步补充通知消息管理示例。
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var allowedMessageTypes = map[string]bool{"notification": true, "atme": true}
|
||||
var allowedAtmeableTypes = map[string]bool{"Journal": true, "Issue": true, "PullRequest": true}
|
||||
|
||||
// Shortcuts returns notification and message OpenAPI shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List user messages and notifications",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name", Required: true},
|
||||
{Name: "type", Short: "t", Usage: "Message type: notification or atme"},
|
||||
{Name: "status", Short: "s", Usage: "Read status: unread/1 or read/2"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: runList,
|
||||
},
|
||||
{
|
||||
Name: "mark-read",
|
||||
Description: "Mark messages as read by IDs or all unread messages",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name", Required: true},
|
||||
{Name: "ids", Short: "i", Usage: "Comma-separated message IDs, for example: 101,102"},
|
||||
{Name: "all-unread", Usage: "Mark all unread messages as read using the OpenAPI -1 sentinel", Bool: true, Default: "false"},
|
||||
{Name: "type", Short: "t", Usage: "Message type: notification or atme"},
|
||||
{Name: "dry-run", Usage: "Preview the request body without changing messages", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runMarkRead,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete messages by IDs",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name", Required: true},
|
||||
{Name: "ids", Short: "i", Usage: "Comma-separated message IDs, for example: 101,102", Required: true},
|
||||
{Name: "type", Short: "t", Usage: "Message type: notification or atme"},
|
||||
{Name: "dry-run", Usage: "Preview the request body without deleting messages", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
{
|
||||
Name: "create-atme",
|
||||
Description: "Create @me notifications for users on an Issue, PullRequest, or Journal",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name used in the API path", Required: true},
|
||||
{Name: "receivers", Short: "r", Usage: "Comma-separated receiver login names", Required: true},
|
||||
{Name: "atmeable-type", Usage: "Mention target type: Journal, Issue, or PullRequest", Required: true},
|
||||
{Name: "atmeable-id", Usage: "Mention target database ID", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the request body without creating messages", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runCreateAtme,
|
||||
},
|
||||
{
|
||||
Name: "platform-settings",
|
||||
Description: "List platform message setting templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "settings",
|
||||
Description: "List user message settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", userMessageSettingsPath(user), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "settings-update",
|
||||
Description: "Update user message settings while preserving unspecified keys",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login name", Required: true},
|
||||
{Name: "notification", Usage: "Comma-separated station-message settings, for example: Normal::Project=true,ManageProject::Issue=false"},
|
||||
{Name: "email", Usage: "Comma-separated email settings, for example: Normal::Project=false,ManageProject::Issue=true"},
|
||||
{Name: "dry-run", Usage: "Preview the merged settings without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runSettingsUpdate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runList(ctx *common.RuntimeContext) error {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageType, err := normalizeMessageType(ctx.Arg("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := normalizeMessageStatus(ctx.Arg("status"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("page", defaultString(ctx.Arg("page"), "1"))
|
||||
q.Set("limit", defaultString(ctx.Arg("limit"), "20"))
|
||||
if messageType != "" {
|
||||
q.Set("type", messageType)
|
||||
}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", userMessagesPath(user), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runMarkRead(ctx *common.RuntimeContext) error {
|
||||
user, payload, err := messageActionPayload(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("mark-read", userMessagesReadPath(user), payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", userMessagesReadPath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
user, payload, err := messageActionPayload(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("delete", userMessagesPath(user), payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", userMessagesPath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runCreateAtme(ctx *common.RuntimeContext) error {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receivers, err := parseStringList(ctx.Arg("receivers"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(receivers) == 0 {
|
||||
return fmt.Errorf("required flag --receivers is missing")
|
||||
}
|
||||
atmeableType, err := normalizeAtmeableType(ctx.Arg("atmeable-type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
atmeableID, err := parsePositiveInt(ctx.Arg("atmeable-id"), "atmeable-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": "atme",
|
||||
"receivers_login": receivers,
|
||||
"atmeable_type": atmeableType,
|
||||
"atmeable_id": atmeableID,
|
||||
}
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("create-atme", userMessagesPath(user), payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", userMessagesPath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runSettingsUpdate(ctx *common.RuntimeContext) error {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notificationUpdates, err := parseBoolPairs(ctx.Arg("notification"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emailUpdates, err := parseBoolPairs(ctx.Arg("email"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(notificationUpdates) == 0 && len(emailUpdates) == 0 {
|
||||
return fmt.Errorf("at least one of --notification or --email is required")
|
||||
}
|
||||
|
||||
current, err := fetchMessageSettings(ctx, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch message settings: %w", err)
|
||||
}
|
||||
payload, err := mergedSettingsPayload(current, notificationUpdates, emailUpdates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("settings-update", userMessageSettingsUpdatePath(user), payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", userMessageSettingsUpdatePath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func userMessagesPath(user string) string {
|
||||
return fmt.Sprintf("/users/%s/messages", user)
|
||||
}
|
||||
|
||||
func userMessagesReadPath(user string) string {
|
||||
return fmt.Sprintf("%s/read", userMessagesPath(user))
|
||||
}
|
||||
|
||||
func userMessageSettingsPath(user string) string {
|
||||
return fmt.Sprintf("/users/%s/template_message_settings", user)
|
||||
}
|
||||
|
||||
func userMessageSettingsUpdatePath(user string) string {
|
||||
return fmt.Sprintf("%s/update_setting", userMessageSettingsPath(user))
|
||||
}
|
||||
|
||||
func messageActionPayload(ctx *common.RuntimeContext, allowAllUnread bool) (string, map[string]interface{}, error) {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
messageType, err := normalizeMessageType(ctx.Arg("type"))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ids, err := parseMessageIDs(ctx.Arg("ids"), parseBool(ctx.Arg("all-unread")), allowAllUnread)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
payload := map[string]interface{}{"ids": ids}
|
||||
if messageType != "" {
|
||||
payload["type"] = messageType
|
||||
}
|
||||
return user, payload, nil
|
||||
}
|
||||
|
||||
func normalizeMessageType(value string) (string, error) {
|
||||
messageType := strings.ToLower(strings.TrimSpace(value))
|
||||
if messageType == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !allowedMessageTypes[messageType] {
|
||||
return "", fmt.Errorf("invalid --type %q: use notification or atme", value)
|
||||
}
|
||||
return messageType, nil
|
||||
}
|
||||
|
||||
func normalizeMessageStatus(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "unread", "1":
|
||||
return "1", nil
|
||||
case "read", "2":
|
||||
return "2", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --status %q: use unread/1 or read/2", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAtmeableType(value string) (string, error) {
|
||||
atmeableType := strings.TrimSpace(value)
|
||||
if atmeableType == "" {
|
||||
return "", fmt.Errorf("required flag --atmeable-type is missing")
|
||||
}
|
||||
for allowed := range allowedAtmeableTypes {
|
||||
if strings.EqualFold(atmeableType, allowed) {
|
||||
return allowed, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("invalid --atmeable-type %q: use Journal, Issue, or PullRequest", value)
|
||||
}
|
||||
|
||||
func parseMessageIDs(value string, allUnread bool, allowAllUnread bool) ([]int, error) {
|
||||
if allUnread && value != "" {
|
||||
return nil, fmt.Errorf("use either --ids or --all-unread, not both")
|
||||
}
|
||||
if allUnread {
|
||||
if !allowAllUnread {
|
||||
return nil, fmt.Errorf("--all-unread is only supported by notification +mark-read")
|
||||
}
|
||||
return []int{-1}, nil
|
||||
}
|
||||
parts, err := parseStringList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("required flag --ids is missing")
|
||||
}
|
||||
ids := make([]int, 0, len(parts))
|
||||
seen := map[int]bool{}
|
||||
for _, part := range parts {
|
||||
id, err := parsePositiveInt(part, "ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(value, flagName string) (int, error) {
|
||||
id, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || id <= 0 {
|
||||
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func parseStringList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, part := range parts {
|
||||
text := strings.TrimSpace(part)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if seen[text] {
|
||||
continue
|
||||
}
|
||||
seen[text] = true
|
||||
values = append(values, text)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parseBoolPairs(value string) (map[string]bool, error) {
|
||||
pairs := map[string]bool{}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return pairs, nil
|
||||
}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
item := strings.TrimSpace(part)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
key, rawValue, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid setting %q: use key=true or key=false", item)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid setting %q: key is empty", item)
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(rawValue))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid boolean value in %q: use true or false", item)
|
||||
}
|
||||
pairs[key] = parsed
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func fetchMessageSettings(ctx *common.RuntimeContext, user string) (map[string]interface{}, error) {
|
||||
env, err := ctx.CallAPI("GET", userMessageSettingsPath(user), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse message settings")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func mergedSettingsPayload(current map[string]interface{}, notificationUpdates, emailUpdates map[string]bool) (map[string]interface{}, error) {
|
||||
notificationBody, err := boolMapFromInterface(current["notification_body"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse notification_body: %w", err)
|
||||
}
|
||||
emailBody, err := boolMapFromInterface(current["email_body"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse email_body: %w", err)
|
||||
}
|
||||
for key, value := range notificationUpdates {
|
||||
notificationBody[key] = value
|
||||
}
|
||||
for key, value := range emailUpdates {
|
||||
emailBody[key] = value
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"setting": map[string]interface{}{
|
||||
"notification_body": notificationBody,
|
||||
"email_body": emailBody,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func boolMapFromInterface(value interface{}) (map[string]bool, error) {
|
||||
body, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected object")
|
||||
}
|
||||
result := make(map[string]bool, len(body))
|
||||
for key, raw := range body {
|
||||
parsed, ok := raw.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not a boolean", key)
|
||||
}
|
||||
result[key] = parsed
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func dryRunData(action, path string, payload map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": action,
|
||||
"method": dryRunMethod(action),
|
||||
"path": path,
|
||||
"body": payload,
|
||||
}
|
||||
}
|
||||
|
||||
func dryRunMethod(action string) string {
|
||||
switch action {
|
||||
case "mark-read", "create-atme", "settings-update":
|
||||
return "POST"
|
||||
case "delete":
|
||||
return "DELETE"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestNotificationListBuildsQuery(t *testing.T) {
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/users/alice/messages.json")
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("page"), "2")
|
||||
assertEqual(t, query.Get("limit"), "5")
|
||||
assertEqual(t, query.Get("type"), "atme")
|
||||
assertEqual(t, query.Get("status"), "1")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "messages": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "list", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "atme",
|
||||
"status": "unread",
|
||||
"page": "2",
|
||||
"limit": "5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationMarkReadPayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/users/alice/messages/read.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "mark-read", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "101,102,101",
|
||||
"type": "notification",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mark-read shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["type"], "notification")
|
||||
assertNumberSlice(t, payload["ids"], []float64{101, 102})
|
||||
}
|
||||
|
||||
func TestNotificationMarkReadAllUnreadDryRunDoesNotCallAPI(t *testing.T) {
|
||||
called := false
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "mark-read", map[string]string{
|
||||
"user": "alice",
|
||||
"all-unread": "true",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mark-read dry-run failed: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("dry-run called API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDeletePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "DELETE", "/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "delete", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "201,202",
|
||||
"type": "atme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["type"], "atme")
|
||||
assertNumberSlice(t, payload["ids"], []float64{201, 202})
|
||||
}
|
||||
|
||||
func TestNotificationCreateAtmePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "create-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob,carol,bob",
|
||||
"atmeable-type": "issue",
|
||||
"atmeable-id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-atme shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["type"], "atme")
|
||||
assertStringSlice(t, payload["receivers_login"], []string{"bob", "carol"})
|
||||
assertEqual(t, payload["atmeable_type"], "Issue")
|
||||
assertEqual(t, payload["atmeable_id"], float64(99))
|
||||
}
|
||||
|
||||
func TestNotificationPlatformSettings(t *testing.T) {
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/template_message_settings.json")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "setting_types": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runNotificationShortcut(t, server, "platform-settings", nil); err != nil {
|
||||
t.Fatalf("platform-settings shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettings(t *testing.T) {
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/users/alice/template_message_settings.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
"notification_body": map[string]bool{"Normal::Project": true},
|
||||
"email_body": map[string]bool{"Normal::Project": false},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runNotificationShortcut(t, server, "settings", map[string]string{"user": "alice"}); err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsUpdatePreservesExistingKeys(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/alice/template_message_settings.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
"notification_body": map[string]bool{
|
||||
"Normal::Project": true,
|
||||
"ManageProject::Issue": true,
|
||||
},
|
||||
"email_body": map[string]bool{
|
||||
"Normal::Project": false,
|
||||
"ManageProject::Issue": false,
|
||||
},
|
||||
})
|
||||
case r.Method == "POST" && r.URL.Path == "/users/alice/template_message_settings/update_setting.json":
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "响应成功"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runNotificationShortcut(t, server, "settings-update", map[string]string{
|
||||
"user": "alice",
|
||||
"notification": "ManageProject::Issue=false",
|
||||
"email": "Normal::Project=true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings-update shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
setting, ok := payload["setting"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("payload setting type = %T, want map", payload["setting"])
|
||||
}
|
||||
notificationBody, ok := setting["notification_body"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notification_body type = %T, want map", setting["notification_body"])
|
||||
}
|
||||
emailBody, ok := setting["email_body"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("email_body type = %T, want map", setting["email_body"])
|
||||
}
|
||||
assertEqual(t, notificationBody["Normal::Project"], true)
|
||||
assertEqual(t, notificationBody["ManageProject::Issue"], false)
|
||||
assertEqual(t, emailBody["Normal::Project"], true)
|
||||
assertEqual(t, emailBody["ManageProject::Issue"], false)
|
||||
}
|
||||
|
||||
func TestNotificationRejectsInvalidInputs(t *testing.T) {
|
||||
if _, err := normalizeMessageType("chat"); err == nil {
|
||||
t.Fatal("expected invalid message type to fail")
|
||||
}
|
||||
if _, err := normalizeMessageStatus("done"); err == nil {
|
||||
t.Fatal("expected invalid status to fail")
|
||||
}
|
||||
if _, err := normalizeAtmeableType("Repository"); err == nil {
|
||||
t.Fatal("expected invalid atmeable type to fail")
|
||||
}
|
||||
if _, err := parseBoolPairs("Normal::Project=yes"); err == nil {
|
||||
t.Fatal("expected invalid boolean setting to fail")
|
||||
}
|
||||
if _, err := parseMessageIDs("1", true, true); err == nil {
|
||||
t.Fatal("expected --ids with --all-unread to fail")
|
||||
}
|
||||
if _, err := parseMessageIDs("", true, false); err == nil {
|
||||
t.Fatal("expected all-unread to be rejected when not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func runNotificationShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findNotificationShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findNotificationShortcut(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 newNotificationTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSlice(t *testing.T, got interface{}, want []string) {
|
||||
t.Helper()
|
||||
values, ok := got.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want []interface{}", got)
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("got value %v (%T), want string", value, value)
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
if !reflect.DeepEqual(result, want) {
|
||||
t.Fatalf("got %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNumberSlice(t *testing.T, got interface{}, want []float64) {
|
||||
t.Helper()
|
||||
values, ok := got.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want []interface{}", got)
|
||||
}
|
||||
result := make([]float64, 0, len(values))
|
||||
for _, value := range values {
|
||||
number, ok := value.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("got value %v (%T), want float64", value, value)
|
||||
}
|
||||
result = append(result, number)
|
||||
}
|
||||
if !reflect.DeepEqual(result, want) {
|
||||
t.Fatalf("got %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/notification"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
||||
|
|
@ -32,45 +33,47 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
|||
tr = translators[0]
|
||||
}
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(tr),
|
||||
"issue": issue.Shortcuts(tr),
|
||||
"label": label.Shortcuts(),
|
||||
"license": license.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"pipeline": pipeline.Shortcuts(),
|
||||
"pr": pr.Shortcuts(tr),
|
||||
"release": release.Shortcuts(tr),
|
||||
"branch": branch.Shortcuts(tr),
|
||||
"org": org.Shortcuts(tr),
|
||||
"user": user.Shortcuts(tr),
|
||||
"search": search.Shortcuts(tr),
|
||||
"ci": ci.Shortcuts(tr),
|
||||
"compare": compare.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(tr),
|
||||
"health": health.Shortcuts(tr),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
"repo": repo.Shortcuts(tr),
|
||||
"issue": issue.Shortcuts(tr),
|
||||
"label": label.Shortcuts(),
|
||||
"license": license.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"notification": notification.Shortcuts(),
|
||||
"pipeline": pipeline.Shortcuts(),
|
||||
"pr": pr.Shortcuts(tr),
|
||||
"release": release.Shortcuts(tr),
|
||||
"branch": branch.Shortcuts(tr),
|
||||
"org": org.Shortcuts(tr),
|
||||
"user": user.Shortcuts(tr),
|
||||
"search": search.Shortcuts(tr),
|
||||
"ci": ci.Shortcuts(tr),
|
||||
"compare": compare.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(tr),
|
||||
"health": health.Shortcuts(tr),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"pipeline": "Pipeline operations",
|
||||
"pr": tr.T("cmd.pr.short"),
|
||||
"release": tr.T("cmd.release.short"),
|
||||
"branch": tr.T("cmd.branch.short"),
|
||||
"org": tr.T("cmd.org.short"),
|
||||
"user": tr.T("cmd.user.short"),
|
||||
"search": tr.T("cmd.search.short"),
|
||||
"ci": tr.T("cmd.ci.short"),
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"webhook": tr.T("cmd.webhook.short"),
|
||||
"health": "Project health data collection",
|
||||
"workflow": "AI agent workflow analysis",
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"notification": "Notification and message operations",
|
||||
"pipeline": "Pipeline operations",
|
||||
"pr": tr.T("cmd.pr.short"),
|
||||
"release": tr.T("cmd.release.short"),
|
||||
"branch": tr.T("cmd.branch.short"),
|
||||
"org": tr.T("cmd.org.short"),
|
||||
"user": tr.T("cmd.user.short"),
|
||||
"search": tr.T("cmd.search.short"),
|
||||
"ci": tr.T("cmd.ci.short"),
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"webhook": tr.T("cmd.webhook.short"),
|
||||
"health": "Project health data collection",
|
||||
"workflow": "AI agent workflow analysis",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
|
|||
"repo", "issue", "label", "license", "pr", "release", "branch",
|
||||
"org", "user", "search", "ci", "workflow",
|
||||
"compare", "member", "milestone", "pipeline", "webhook",
|
||||
"health",
|
||||
"health", "notification",
|
||||
}
|
||||
|
||||
groupSet := map[string]bool{}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ skills/
|
|||
│ └── search-workflow.md # 搜索工作流
|
||||
├── gitlink-user/ # 用户管理
|
||||
│ └── SKILL.md # 用户操作指南
|
||||
├── gitlink-notification/ # 通知消息管理
|
||||
│ └── SKILL.md # 消息与设置操作指南
|
||||
├── gitlink-org/ # 组织管理
|
||||
│ ├── SKILL.md # 组织操作指南
|
||||
│ └── examples/
|
||||
|
|
@ -147,6 +149,7 @@ skills/
|
|||
|-------|------|----------|
|
||||
| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` |
|
||||
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
|
||||
| **gitlink-notification** | 通知消息管理 | `notification +list`, `notification +mark-read`, `notification +settings-update` |
|
||||
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
|
||||
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
|
||||
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
|
||||
|
|
@ -254,9 +257,10 @@ gitlink-cli org +info -i Gitlink
|
|||
- [gitlink-pipeline/SKILL.md](gitlink-pipeline/SKILL.md) - Pipeline 命令
|
||||
- [gitlink-search/SKILL.md](gitlink-search/SKILL.md) - 搜索命令
|
||||
|
||||
**组织和用户**:
|
||||
**组织、用户和通知**:
|
||||
- [gitlink-org/SKILL.md](gitlink-org/SKILL.md) - 组织命令
|
||||
- [gitlink-user/SKILL.md](gitlink-user/SKILL.md) - 用户命令
|
||||
- [gitlink-notification/SKILL.md](gitlink-notification/SKILL.md) - 通知消息命令
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
name: gitlink-notification
|
||||
version: 1.0.0
|
||||
description: "通知消息操作:查看消息、标记已读、删除消息、创建 @我通知、查看和更新消息设置。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli notification --help"
|
||||
---
|
||||
|
||||
# gitlink-notification(通知消息管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — `notification +mark-read`、`notification +delete`、`notification +create-atme`、`notification +settings-update` 都会修改远端数据,执行前必须先向用户展示 `--dry-run` 结果并获得确认。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
> **适用场景:** Agent 帮用户整理 GitLink 站内消息、批量标记已读、清理指定消息、在已有 Issue/PR/Journal 上补发 @我通知、检查或调整消息/邮件通知配置。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 | 读/写 |
|
||||
|----------|------|-------|
|
||||
| `notification +list` | 查询用户消息列表,支持 `notification` / `atme` 与已读状态过滤 | 读 |
|
||||
| `notification +mark-read` | 按消息 ID 标记已读,或使用 `--all-unread` 标记全部未读 | 写 |
|
||||
| `notification +delete` | 按消息 ID 删除消息 | 写 |
|
||||
| `notification +create-atme` | 基于 Issue、PullRequest 或 Journal 创建 @我通知 | 写 |
|
||||
| `notification +platform-settings` | 查看平台消息模板配置 | 读 |
|
||||
| `notification +settings` | 查看指定用户消息设置 | 读 |
|
||||
| `notification +settings-update` | 更新用户消息/邮件设置,未指定 key 会保留原值 | 写 |
|
||||
|
||||
## 参数约定
|
||||
|
||||
- `--user`:OpenAPI 路径中的用户标识,如 `wangyue111`。
|
||||
- `--type`:消息类型,取值:`notification`(系统消息)或 `atme`(@我消息)。不传表示不限定类型。
|
||||
- `--status`:列表过滤:`unread`/`1` 表示未读,`read`/`2` 表示已读。
|
||||
- `--ids`:消息 ID 列表,例如 `101,102,103`。
|
||||
- `--all-unread`:仅用于 `notification +mark-read`,对应 OpenAPI 的 `ids: [-1]`。
|
||||
- `--atmeable-type`:@我消息来源对象,取值:`Journal`、`Issue`、`PullRequest`。
|
||||
- `--notification` / `--email`:消息设置键值对,格式 `Key=true,OtherKey=false`,例如 `Normal::Project=true`。
|
||||
|
||||
## 安全工作流
|
||||
|
||||
写操作必须遵循:
|
||||
|
||||
1. 先读取用户输入并确认目标用户、消息 ID / 设置 key。
|
||||
2. 先执行带 `--dry-run` 的命令,展示将要请求的 method/path/body。
|
||||
3. 用户确认后再去掉 `--dry-run` 执行真实写操作。
|
||||
4. 执行后用 `--format json` 保留结构化结果,便于答辩或审计复现。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 查看未读 @我消息
|
||||
gitlink-cli notification +list --user wangyue111 --type atme --status unread --format json
|
||||
|
||||
# 预览标记指定消息为已读
|
||||
gitlink-cli notification +mark-read --user wangyue111 --ids 101,102 --dry-run --format json
|
||||
|
||||
# 确认后真实标记已读
|
||||
gitlink-cli notification +mark-read --user wangyue111 --ids 101,102 --format json
|
||||
|
||||
# 预览标记全部未读为已读
|
||||
gitlink-cli notification +mark-read --user wangyue111 --all-unread --type notification --dry-run --format json
|
||||
|
||||
# 预览删除指定消息
|
||||
gitlink-cli notification +delete --user wangyue111 --ids 201,202 --type notification --dry-run --format json
|
||||
|
||||
# 创建绑定到 Issue 的 @我通知(先 dry-run)
|
||||
gitlink-cli notification +create-atme \
|
||||
--user wangyue111 \
|
||||
--receivers reviewer1,reviewer2 \
|
||||
--atmeable-type Issue \
|
||||
--atmeable-id 99 \
|
||||
--dry-run \
|
||||
--format json
|
||||
|
||||
# 查看平台消息模板和用户当前设置
|
||||
gitlink-cli notification +platform-settings --format json
|
||||
gitlink-cli notification +settings --user wangyue111 --format json
|
||||
|
||||
# 预览更新消息设置:只修改指定 key,其余配置保留
|
||||
gitlink-cli notification +settings-update \
|
||||
--user wangyue111 \
|
||||
--notification Normal::Project=true,ManageProject::Issue=false \
|
||||
--email Normal::Project=false \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
## OpenAPI 映射
|
||||
|
||||
| Shortcut | Method | Path |
|
||||
|----------|--------|------|
|
||||
| `notification +list` | `GET` | `/api/users/{owner}/messages.json` |
|
||||
| `notification +mark-read` | `POST` | `/api/users/{owner}/messages/read.json` |
|
||||
| `notification +delete` | `DELETE` | `/api/users/{owner}/messages.json` |
|
||||
| `notification +create-atme` | `POST` | `/api/users/{owner}/messages.json` |
|
||||
| `notification +platform-settings` | `GET` | `/api/template_message_settings.json` |
|
||||
| `notification +settings` | `GET` | `/api/users/{owner}/template_message_settings.json` |
|
||||
| `notification +settings-update` | `POST` | `/api/users/{owner}/template_message_settings/update_setting.json` |
|
||||
|
||||
## Agent 提示模板
|
||||
|
||||
当用户要求“帮我清理/整理 GitLink 通知”时:
|
||||
|
||||
1. 先问清楚目标用户和范围(只看 @我、只看未读、还是全部消息)。
|
||||
2. 使用 `notification +list` 获取候选消息。
|
||||
3. 对标记已读、删除、更新设置等写操作,先执行 `--dry-run`。
|
||||
4. 把 dry-run 中的 `method`、`path`、`body` 展示给用户确认。
|
||||
5. 用户确认后执行真实命令,并总结成功/失败结果。
|
||||
Loading…
Reference in New Issue