Merge PR #76: feat(notification): add OpenAPI shortcuts
# Conflicts: # README.md # README.zh-CN.md # doc/changes/notification-shortcut.md # shortcuts/notification/notification.go # shortcuts/notification/notification_test.go # shortcuts/register.go # shortcuts/register_test.go # skills/gitlink-notification/SKILL.md
This commit is contained in:
commit
a61725c569
38
README.md
38
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, attachments, metadata lookup, 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, Attachment, Metadata, 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,8 +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 |
|
||||
| 📎 Attachment | Upload files and delete attachments with dry-run safety |
|
||||
| 🧭 Metadata | List license and .gitignore templates |
|
||||
| 🔔 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 |
|
||||
|
|
@ -479,23 +478,25 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Attachment and Metadata
|
||||
### Notification Management
|
||||
|
||||
```bash
|
||||
# List license templates
|
||||
gitlink-cli meta +licenses --name MIT
|
||||
# List unread @me messages
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# List .gitignore templates
|
||||
gitlink-cli meta +ignores --name Go
|
||||
# Preview marking messages as read
|
||||
gitlink-cli notification +mark-read --user zhangsan --ids 101,102 --dry-run
|
||||
|
||||
# Preview an attachment upload without changing data
|
||||
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot" --dry-run
|
||||
# Delete messages by IDs
|
||||
gitlink-cli notification +delete --user zhangsan --ids 101,102 --type notification
|
||||
|
||||
# Upload an attachment
|
||||
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot"
|
||||
# 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
|
||||
|
||||
# Preview attachment deletion
|
||||
gitlink-cli attachment +delete --uuid <uuid> --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
|
||||
|
|
@ -666,11 +667,10 @@ See [skills/README.md](skills/README.md) for details.
|
|||
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
|
||||
| `gitlink-attachment` | Attachment upload and deletion workflows |
|
||||
| `gitlink-meta` | Read-only license and .gitignore template lookup |
|
||||
| `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) |
|
||||
|
|
@ -704,8 +704,7 @@ gitlink-cli/
|
|||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # Search shortcuts
|
||||
│ ├── user/ # User shortcuts
|
||||
│ ├── attachment/ # Attachment shortcuts
|
||||
│ ├── meta/ # Metadata shortcuts
|
||||
│ ├── notification/ # Notification shortcuts
|
||||
│ └── register.go # Registration entry point
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills guide
|
||||
|
|
@ -713,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,8 +105,7 @@
|
|||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 📎 附件 | 上传文件、删除附件,支持 dry-run 预览 |
|
||||
| 🧭 元数据 | 查询许可证模板和 .gitignore 模板 |
|
||||
| 🔔 通知消息 | 查看消息、标记已读、删除消息、创建 @我通知、管理消息设置 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
|
|
@ -457,23 +456,25 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### 附件和元数据
|
||||
### 通知消息管理
|
||||
|
||||
```bash
|
||||
# 查询许可证模板
|
||||
gitlink-cli meta +licenses --name MIT
|
||||
# 查看未读 @我消息
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# 查询 .gitignore 模板
|
||||
gitlink-cli meta +ignores --name Go
|
||||
# 预览标记已读,不修改线上数据
|
||||
gitlink-cli notification +mark-read --user zhangsan --ids 101,102 --dry-run
|
||||
|
||||
# 预览附件上传,不修改线上数据
|
||||
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot" --dry-run
|
||||
# 按 ID 删除消息
|
||||
gitlink-cli notification +delete --user zhangsan --ids 101,102 --type notification
|
||||
|
||||
# 上传附件
|
||||
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot"
|
||||
# 创建绑定到 Issue、PR 或 Journal 的 @我通知
|
||||
gitlink-cli notification +create-atme --user zhangsan --receivers lisi,wangwu --atmeable-type Issue --atmeable-id 99 --dry-run
|
||||
|
||||
# 预览删除附件
|
||||
gitlink-cli attachment +delete --uuid <uuid> --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
|
||||
|
|
@ -545,10 +546,9 @@ git push gitlink
|
|||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
| `gitlink-attachment` | 附件上传与删除操作 |
|
||||
| `gitlink-meta` | 许可证和 .gitignore 模板查询 |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-notification` | 通知消息操作与设置管理 |
|
||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||
| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) |
|
||||
|
||||
|
|
@ -581,8 +581,7 @@ gitlink-cli/
|
|||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ ├── attachment/ # 附件 shortcuts
|
||||
│ ├── meta/ # 元数据 shortcuts
|
||||
│ ├── notification/ # 通知消息 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills 使用指南
|
||||
|
|
@ -590,6 +589,7 @@ gitlink-cli/
|
|||
│ ├── gitlink-repo/ # 仓库 Skill
|
||||
│ ├── gitlink-issue/ # Issue Skill
|
||||
│ ├── gitlink-pr/ # PR Skill
|
||||
│ ├── gitlink-notification/ # 通知消息 Skill
|
||||
│ ├── gitlink-pm/ # 项目管理 Skill
|
||||
│ └── ...
|
||||
├── doc/ # 设计文档
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
# Notification Shortcut
|
||||
|
||||
## Summary
|
||||
新增 `notification` Shortcut 组,补齐 GitLink OpenAPI 中用户消息与消息设置相关接口的高层封装:
|
||||
|
||||
Adds a `notification` shortcut group for GitLink user messages. The group supports listing messages, marking messages as read, and deleting messages without requiring raw API calls.
|
||||
- `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`:更新用户消息/邮件设置。
|
||||
|
||||
## Commands
|
||||
实现要点:
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `gitlink-cli notification +list` | List messages for the current or specified user |
|
||||
| `gitlink-cli notification +read` | Mark specific messages, or all unread messages, as read |
|
||||
| `gitlink-cli notification +delete` | Delete specific messages |
|
||||
|
||||
## Behavior
|
||||
|
||||
- `+list` supports `--type notification|atme|all`, `--status unread|read|all`, and pagination.
|
||||
- `+read` and `+delete` require `--type notification|atme`.
|
||||
- `+read --ids -1` marks all unread messages of the selected type as read.
|
||||
- `+delete` rejects `--ids -1` to avoid accidental bulk deletion.
|
||||
- When `--user` is omitted, the shortcut resolves the current authenticated user via `/users/me`.
|
||||
|
||||
## Tests
|
||||
|
||||
The unit tests verify current-user resolution, explicit-user paths, query parameters, read/delete payloads, duplicate ID removal, all-unread handling, and validation failures.
|
||||
- 写操作均支持 `--dry-run`,可先输出 method/path/body 供用户或 Agent 确认。
|
||||
- `settings-update` 会先读取当前用户设置,再合并 CLI 指定的 key,避免未指定配置被覆盖。
|
||||
- 参数校验覆盖消息类型、已读状态、@我对象类型、消息 ID、布尔配置项等常见误用场景。
|
||||
- 补充单元测试覆盖 HTTP method/path/query/payload、dry-run 不触发 API、设置合并保留原值等场景。
|
||||
- README / README.zh-CN / Skills 文档同步补充通知消息管理示例。
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package notification implements GitLink user message shortcuts.
|
||||
package notification
|
||||
|
||||
import (
|
||||
|
|
@ -7,107 +6,145 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const allUnreadMessageID = -1
|
||||
|
||||
// Shortcuts returns user notification/message shortcuts.
|
||||
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||
tr := shortcutTranslator(translators...)
|
||||
userFlag := common.Flag{Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}
|
||||
typeFlag := common.Flag{Name: "type", Short: "t", Usage: tr.T("flag.notification.type")}
|
||||
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: tr.T("cmd.notification.list.short"),
|
||||
Description: "List user messages and notifications",
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "status", Short: "s", Usage: tr.T("flag.notification.status")},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
{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: "read",
|
||||
Description: tr.T("cmd.notification.read.short"),
|
||||
Name: "mark-read",
|
||||
Description: "Mark messages as read by IDs or all unread messages",
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids")},
|
||||
{Name: "all-unread", Usage: tr.T("flag.notification.all_unread"), Bool: true, Default: "false"},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
{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: runRead,
|
||||
Run: runMarkRead,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: tr.T("cmd.notification.delete.short"),
|
||||
Description: "Delete messages by IDs",
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids"), Required: true},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
{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: "send-atme",
|
||||
Description: tr.T("cmd.notification.send_atme.short"),
|
||||
Name: "create-atme",
|
||||
Description: "Create @me notifications for users on an Issue, PullRequest, or Journal",
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
{Name: "receivers", Short: "r", Usage: tr.T("flag.notification.receivers"), Required: true},
|
||||
{Name: "atmeable-type", Usage: tr.T("flag.notification.atmeable_type"), Required: true},
|
||||
{Name: "atmeable-id", Usage: tr.T("flag.notification.atmeable_id"), Required: true},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
{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: runSendAtme,
|
||||
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 := resolveUser(ctx)
|
||||
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{}
|
||||
setQueryIfPresent(q, "type", ctx.Arg("type"))
|
||||
setQueryIfPresent(q, "status", normalizeStatus(ctx.Arg("status")))
|
||||
q.Set("page", firstNonEmpty(ctx.Arg("page"), "1"))
|
||||
q.Set("limit", firstNonEmpty(ctx.Arg("limit"), "20"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", messagesPath(user), q)
|
||||
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 runRead(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
func runMarkRead(ctx *common.RuntimeContext) error {
|
||||
user, payload, err := messageActionPayload(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := readPayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("mark-read", userMessagesReadPath(user), payload))
|
||||
}
|
||||
path := messagesReadPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "read_notifications", "POST", path, user, payload)
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("marking messages as read changes remote state; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
env, err := ctx.CallAPI("POST", userMessagesReadPath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -115,220 +152,334 @@ func runRead(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
user, payload, err := messageActionPayload(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseIDs(ctx.Arg("ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(dryRunData("delete", userMessagesPath(user), payload))
|
||||
}
|
||||
payload := messageActionPayload(ctx.Arg("type"), ids)
|
||||
path := messagesPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "delete_notifications", "DELETE", path, user, payload)
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("deleting messages is destructive; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", path, payload)
|
||||
env, err := ctx.CallAPI("DELETE", userMessagesPath(user), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runSendAtme(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
func runCreateAtme(ctx *common.RuntimeContext) error {
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := sendAtmePayload(ctx)
|
||||
receivers, err := parseStringList(ctx.Arg("receivers"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := messagesPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "send_atme", "POST", path, user, payload)
|
||||
if len(receivers) == 0 {
|
||||
return fmt.Errorf("required flag --receivers is missing")
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("sending @ messages changes remote state; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
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
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func readPayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
if ctx.Arg("all-unread") == "true" {
|
||||
if strings.TrimSpace(ctx.Arg("ids")) != "" {
|
||||
return nil, fmt.Errorf("--ids and --all-unread cannot be used together")
|
||||
}
|
||||
return messageActionPayload(ctx.Arg("type"), []int{allUnreadMessageID}), nil
|
||||
}
|
||||
ids, err := parseIDs(ctx.Arg("ids"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messageActionPayload(ctx.Arg("type"), ids), nil
|
||||
}
|
||||
|
||||
func messageActionPayload(messageType string, ids []int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": firstNonEmpty(strings.TrimSpace(messageType), "notification"),
|
||||
"ids": ids,
|
||||
}
|
||||
}
|
||||
|
||||
func sendAtmePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
receivers, err := parseStringList(ctx.Arg("receivers"), "--receivers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atmeableType, err := ctx.RequireArg("atmeable-type")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atmeableID, err := parsePositiveInt(ctx.Arg("atmeable-id"), "--atmeable-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
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 writeDryRun(ctx *common.RuntimeContext, action, method, path, user string, payload map[string]interface{}) error {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
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": method,
|
||||
"method": dryRunMethod(action),
|
||||
"path": path,
|
||||
"user": user,
|
||||
"payload": payload,
|
||||
})
|
||||
"body": payload,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveUser(ctx *common.RuntimeContext) (string, error) {
|
||||
if user := strings.TrimSpace(ctx.Arg("user")); user != "" {
|
||||
return user, nil
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if login := extractLogin(env); login != "" {
|
||||
return login, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s", ctx.Tr.T("error.notification.user_required"))
|
||||
}
|
||||
|
||||
func extractLogin(env *output.Envelope) string {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
func dryRunMethod(action string) string {
|
||||
switch action {
|
||||
case "mark-read", "create-atme", "settings-update":
|
||||
return "POST"
|
||||
case "delete":
|
||||
return "DELETE"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if login, ok := data["login"].(string); ok {
|
||||
return login
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]int, error) {
|
||||
return parseIntList(raw, "--ids", true)
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseIntList(raw, flag string, positiveOnly bool) ([]int, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s value %q: use comma-separated integers", flag, part)
|
||||
}
|
||||
if positiveOnly && value <= 0 {
|
||||
return nil, fmt.Errorf("invalid %s value %q: use a positive integer", flag, part)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%s must include at least one id", flag)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw, flag string) (int, error) {
|
||||
values, err := parseIntList(raw, flag, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return 0, fmt.Errorf("%s must include exactly one id", flag)
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func parseStringList(raw, flag string) ([]string, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%s must include at least one value", flag)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "unread":
|
||||
return "1"
|
||||
case "read":
|
||||
return "2"
|
||||
default:
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
}
|
||||
|
||||
func messagesPath(user string) string {
|
||||
return fmt.Sprintf("/api/users/%s/messages", url.PathEscape(user))
|
||||
}
|
||||
|
||||
func messagesReadPath(user string) string {
|
||||
return fmt.Sprintf("%s/read", messagesPath(user))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setQueryIfPresent(q url.Values, key, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
q.Set(key, strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
return translators[0]
|
||||
}
|
||||
return i18n.Default()
|
||||
func parseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,38 +8,261 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
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 := findShortcut(t, name)
|
||||
shortcut := findNotificationShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Format: "json",
|
||||
Args: args,
|
||||
Tr: i18n.Default(),
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
func findNotificationShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Name == name {
|
||||
return s
|
||||
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, v interface{}) {
|
||||
func newNotificationTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("write JSON: %v", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,345 +270,60 @@ 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("decode request body: %v", err)
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func assertPath(t *testing.T, r *http.Request, method, path string) {
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
if r.Method != method {
|
||||
t.Fatalf("method = %s, want %s", r.Method, method)
|
||||
}
|
||||
if r.URL.Path != path {
|
||||
t.Fatalf("path = %s, want %s", r.URL.Path, path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
|
||||
func TestNotificationListExplicitUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "GET", "/api/users/alice/messages.json")
|
||||
if got := r.URL.Query().Get("type"); got != "atme" {
|
||||
t.Fatalf("type = %q, want atme", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("status"); got != "1" {
|
||||
t.Fatalf("status = %q, want 1", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("page"); got != "2" {
|
||||
t.Fatalf("page = %q, want 2", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "50" {
|
||||
t.Fatalf("limit = %q, want 50", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 1, "messages": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "list", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "atme",
|
||||
"status": "unread",
|
||||
"page": "2",
|
||||
"limit": "50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %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 TestNotificationListDefaultsToCurrentUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/users/me.json":
|
||||
writeJSON(t, w, map[string]interface{}{"login": "current"})
|
||||
case "/api/users/current/messages.json":
|
||||
if got := r.URL.Query().Get("status"); got != "2" {
|
||||
t.Fatalf("status = %q, want 2", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"messages": []interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runShortcut(t, server, "list", map[string]string{"status": "read"}); err != nil {
|
||||
t.Fatalf("list default user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDefaultUserMissingLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "GET", "/users/me.json")
|
||||
writeJSON(t, w, map[string]interface{}{"name": "No Login"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runShortcut(t, server, "list", nil); err == nil {
|
||||
t.Fatal("expected error when current user login is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
// --- read ---
|
||||
|
||||
func TestNotificationReadDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "1,2,3",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRequiresYesForRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("read without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "ids": "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when read is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadPostsIDs(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "atme",
|
||||
"ids": "4,5",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if payload["type"] != "atme" {
|
||||
t.Fatalf("type = %#v, want atme", payload["type"])
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{4, 5}) {
|
||||
t.Fatalf("ids = %#v, want [4 5]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadAllUnread(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"all-unread": "true",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read all-unread failed: %v", err)
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{-1}) {
|
||||
t.Fatalf("ids = %#v, want [-1]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRejectsIDsWithAllUnread(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "1",
|
||||
"all-unread": "true",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --ids and --all-unread are combined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRequiresIDsOrAllUnread(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "dry-run": "true"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when read has no ids")
|
||||
}
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
func TestNotificationDeleteDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "9",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDeleteRequiresYes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("delete without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{"user": "alice", "ids": "9"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when delete is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDeleteCallsEndpoint(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "DELETE", "/api/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "notification",
|
||||
"ids": "10,11",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{10, 11}) {
|
||||
t.Fatalf("ids = %#v, want [10 11]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- send-atme ---
|
||||
|
||||
func TestNotificationSendAtmeDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob,carol",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "42",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send-atme dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeRequiresYes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("send-atme without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "42",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when send-atme is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeCallsEndpoint(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob, carol",
|
||||
"atmeable-type": "PullRequest",
|
||||
"atmeable-id": "77",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send-atme failed: %v", err)
|
||||
}
|
||||
if payload["type"] != "atme" {
|
||||
t.Fatalf("type = %#v, want atme", payload["type"])
|
||||
}
|
||||
if got, ok := payload["receivers_login"].([]interface{}); !ok || len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
|
||||
t.Fatalf("receivers_login = %#v, want [bob carol]", payload["receivers_login"])
|
||||
}
|
||||
if payload["atmeable_type"] != "PullRequest" {
|
||||
t.Fatalf("atmeable_type = %#v, want PullRequest", payload["atmeable_type"])
|
||||
}
|
||||
if payload["atmeable_id"] != float64(77) {
|
||||
t.Fatalf("atmeable_id = %#v, want 77", payload["atmeable_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeRejectsInvalidID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "0",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid atmeable-id error")
|
||||
}
|
||||
}
|
||||
|
||||
func floatSliceToInts(raw interface{}) []int {
|
||||
values, ok := raw.([]interface{})
|
||||
func assertStringSlice(t *testing.T, got interface{}, want []string) {
|
||||
t.Helper()
|
||||
values, ok := got.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
t.Fatalf("got %T, want []interface{}", got)
|
||||
}
|
||||
ints := make([]int, 0, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if number, ok := value.(float64); ok {
|
||||
ints = append(ints, int(number))
|
||||
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)
|
||||
}
|
||||
return ints
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -14,8 +13,8 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/meta"
|
||||
"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"
|
||||
|
|
@ -34,49 +33,47 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
|||
tr = translators[0]
|
||||
}
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"attachment": attachment.Shortcuts(),
|
||||
"repo": repo.Shortcuts(tr),
|
||||
"issue": issue.Shortcuts(tr),
|
||||
"label": label.Shortcuts(),
|
||||
"license": license.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"meta": meta.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{
|
||||
"attachment": "Attachment upload and delete operations",
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"meta": "GitLink metadata template 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", "attachment", "meta",
|
||||
"health", "notification",
|
||||
}
|
||||
|
||||
groupSet := map[string]bool{}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ skills/
|
|||
│ └── SKILL.md # 许可证和 .gitignore 模板查询指南
|
||||
├── gitlink-user/ # 用户管理
|
||||
│ └── SKILL.md # 用户操作指南
|
||||
├── gitlink-notification/ # 通知消息管理
|
||||
│ └── SKILL.md # 消息与设置操作指南
|
||||
├── gitlink-org/ # 组织管理
|
||||
│ ├── SKILL.md # 组织操作指南
|
||||
│ └── examples/
|
||||
|
|
@ -151,6 +153,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` |
|
||||
|
|
@ -276,9 +279,10 @@ gitlink-cli attachment +upload --file screenshot.png --description "复现截图
|
|||
- [gitlink-attachment/SKILL.md](gitlink-attachment/SKILL.md) - 附件命令
|
||||
- [gitlink-meta/SKILL.md](gitlink-meta/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) - 通知消息命令
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,109 +1,110 @@
|
|||
---
|
||||
name: gitlink-notification
|
||||
version: 1.0.0
|
||||
description: "通知与消息管理:查看 GitLink 通知、标记已读、删除消息、发送 @ 提及消息。当用户需要查看或管理站内消息/通知时触发。"
|
||||
description: "通知消息操作:查看消息、标记已读、删除消息、创建 @我通知、查看和更新消息设置。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli notification --help"
|
||||
---
|
||||
|
||||
# gitlink-notification(通知与消息管理)
|
||||
# gitlink-notification(通知消息管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和安全规则。**
|
||||
**CRITICAL — 标记已读、删除消息和发送 @ 消息都是写操作,执行前必须先 dry-run 并确认用户意图。**
|
||||
**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 资源。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。
|
||||
> **适用场景:** Agent 帮用户整理 GitLink 站内消息、批量标记已读、清理指定消息、在已有 Issue/PR/Journal 上补发 @我通知、检查或调整消息/邮件通知配置。
|
||||
|
||||
## 功能概述
|
||||
## Shortcuts
|
||||
|
||||
GitLink 平台的通知在 API 中称为 messages。本 Skill 使用 `notification` shortcut 管理用户消息:
|
||||
| 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 会保留原值 | 写 |
|
||||
|
||||
| 命令 | 用途 | 是否写操作 |
|
||||
|------|------|------------|
|
||||
| `notification +list` | 列出用户消息和通知 | 否 |
|
||||
| `notification +read` | 将消息标记为已读 | 是 |
|
||||
| `notification +delete` | 删除消息 | 是 |
|
||||
| `notification +send-atme` | 发送 @ 提及消息 | 是 |
|
||||
## 参数约定
|
||||
|
||||
## 常用命令
|
||||
- `--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 --status unread --limit 20 --format json
|
||||
|
||||
# 查看 @ 我消息
|
||||
gitlink-cli notification +list --type atme --status unread --format json
|
||||
|
||||
# 查看指定用户消息
|
||||
gitlink-cli notification +list --user zhangsan --type notification --status read --page 1 --limit 20 --format json
|
||||
# 查看未读 @我消息
|
||||
gitlink-cli notification +list --user wangyue111 --type atme --status unread --format json
|
||||
|
||||
# 预览标记指定消息为已读
|
||||
gitlink-cli notification +read --ids 740214,740213 --dry-run --format json
|
||||
gitlink-cli notification +mark-read --user wangyue111 --ids 101,102 --dry-run --format json
|
||||
|
||||
# 确认标记指定消息为已读
|
||||
gitlink-cli notification +read --ids 740214,740213 --yes --format json
|
||||
# 确认后真实标记已读
|
||||
gitlink-cli notification +mark-read --user wangyue111 --ids 101,102 --format json
|
||||
|
||||
# 预览将全部未读系统通知标记为已读
|
||||
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
|
||||
# 预览标记全部未读为已读
|
||||
gitlink-cli notification +mark-read --user wangyue111 --all-unread --type notification --dry-run --format json
|
||||
|
||||
# 预览删除指定消息
|
||||
gitlink-cli notification +delete --ids 740214,740213 --dry-run --format json
|
||||
gitlink-cli notification +delete --user wangyue111 --ids 201,202 --type notification --dry-run --format json
|
||||
|
||||
# 发送 @ 提及消息,先 dry-run
|
||||
gitlink-cli notification +send-atme --receivers alice,bob \
|
||||
--atmeable-type Issue --atmeable-id 123 --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 映射
|
||||
|
||||
### `notification +list`
|
||||
| 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` |
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--user, -u` | 否 | 目标用户登录名,默认使用当前认证用户 |
|
||||
| `--type, -t` | 否 | 消息类型:`notification` 或 `atme` |
|
||||
| `--status, -s` | 否 | 状态:`unread`/`1` 或 `read`/`2` |
|
||||
| `--page, -p` | 否 | 页码,默认 `1` |
|
||||
| `--limit, -l` | 否 | 每页数量,默认 `20` |
|
||||
## Agent 提示模板
|
||||
|
||||
### `notification +read`
|
||||
当用户要求“帮我清理/整理 GitLink 通知”时:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--ids, -i` | 条件必填 | 消息 ID,多个用英文逗号分隔 |
|
||||
| `--all-unread` | 条件必填 | 将所选类型全部未读消息标记为已读 |
|
||||
| `--type, -t` | 否 | 消息类型,默认 `notification` |
|
||||
| `--dry-run` | 否 | 预览请求,不修改远端 |
|
||||
| `--yes` | 否 | 确认执行远端写入 |
|
||||
|
||||
### `notification +delete`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--ids, -i` | 是 | 要删除的消息 ID,多个用英文逗号分隔 |
|
||||
| `--type, -t` | 否 | 消息类型,默认 `notification` |
|
||||
| `--dry-run` | 否 | 预览删除请求 |
|
||||
| `--yes` | 否 | 确认执行删除 |
|
||||
|
||||
### `notification +send-atme`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--receivers, -r` | 是 | 接收者登录名,多个用英文逗号分隔 |
|
||||
| `--atmeable-type` | 是 | @ 消息目标类型:`Journal`、`Issue` 或 `PullRequest` |
|
||||
| `--atmeable-id` | 是 | @ 消息目标对象 ID |
|
||||
| `--dry-run` | 否 | 预览发送请求 |
|
||||
| `--yes` | 否 | 确认发送 |
|
||||
|
||||
## 安全规则
|
||||
|
||||
- `notification +read`、`notification +delete` 和 `notification +send-atme` 默认不会修改远端状态。
|
||||
- 真实执行前必须先使用 `--dry-run` 查看 `payload`。
|
||||
- 用户明确确认后,才可以加 `--yes` 执行。
|
||||
- `notification +read --all-unread` 会向 API 发送 `ids: [-1]`,表示所选类型的全部未读消息。
|
||||
- `notification +delete` 不支持 `--all-unread`,避免误删大量消息。
|
||||
|
||||
## 参考
|
||||
|
||||
- [gitlink-shared](../gitlink-shared/SKILL.md)
|
||||
- [gitlink-notification-digest](../gitlink-notification-digest/SKILL.md)
|
||||
1. 先问清楚目标用户和范围(只看 @我、只看未读、还是全部消息)。
|
||||
2. 使用 `notification +list` 获取候选消息。
|
||||
3. 对标记已读、删除、更新设置等写操作,先执行 `--dry-run`。
|
||||
4. 把 dry-run 中的 `method`、`path`、`body` 展示给用户确认。
|
||||
5. 用户确认后执行真实命令,并总结成功/失败结果。
|
||||
|
|
|
|||
Loading…
Reference in New Issue