diff --git a/README.md b/README.md index 9c8549e..4b18b08 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](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 --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 diff --git a/README.zh-CN.md b/README.zh-CN.md index bb5bb88..c873aa9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](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 --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/ # 设计文档 diff --git a/doc/changes/notification-shortcut.md b/doc/changes/notification-shortcut.md index d705279..de55bf7 100644 --- a/doc/changes/notification-shortcut.md +++ b/doc/changes/notification-shortcut.md @@ -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 文档同步补充通知消息管理示例。 diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go index 539345d..0cd5d3a 100644 --- a/shortcuts/notification/notification.go +++ b/shortcuts/notification/notification.go @@ -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 } diff --git a/shortcuts/notification/notification_test.go b/shortcuts/notification/notification_test.go index ff6776a..24da821 100644 --- a/shortcuts/notification/notification_test.go +++ b/shortcuts/notification/notification_test.go @@ -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 } diff --git a/shortcuts/register.go b/shortcuts/register.go index f0d8935..21fc5cc 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 578ea5f..d292f18 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -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{} diff --git a/skills/README.md b/skills/README.md index ed6e98f..0336886 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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) - 通知消息命令 --- diff --git a/skills/gitlink-notification/SKILL.md b/skills/gitlink-notification/SKILL.md index 3e58c36..9c349ce 100644 --- a/skills/gitlink-notification/SKILL.md +++ b/skills/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. 用户确认后执行真实命令,并总结成功/失败结果。