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