From fb0ebf976fd763e47355d74a100d87bd3c6c0a05 Mon Sep 17 00:00:00 2001 From: NeeNe <26158277@qq.com> Date: Thu, 2 Jul 2026 12:58:32 +0800 Subject: [PATCH] feat(notification): add message notification shortcuts --- README.md | 25 ++ README.zh-CN.md | 25 ++ doc/changes/notification-shortcuts.md | 82 ++++ internal/i18n/locales/en-US.json | 16 + internal/i18n/locales/zh-CN.json | 16 + shortcuts/notification/notification.go | 334 +++++++++++++++ shortcuts/notification/notification_test.go | 391 ++++++++++++++++++ shortcuts/register.go | 91 ++-- shortcuts/register_test.go | 2 +- skills/README.md | 3 + .../gitlink-notification-digest/EXAMPLES.md | 57 ++- skills/gitlink-notification-digest/SKILL.md | 57 ++- skills/gitlink-notification/SKILL.md | 109 +++++ 13 files changed, 1096 insertions(+), 112 deletions(-) create mode 100644 doc/changes/notification-shortcuts.md create mode 100644 shortcuts/notification/notification.go create mode 100644 shortcuts/notification/notification_test.go create mode 100644 skills/gitlink-notification/SKILL.md diff --git a/README.md b/README.md index e5e4318..5ff8ea7 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,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 messages, send @ mentions | | 📖 Wiki | List, view, create, update, and delete wiki pages | | 🔍 Search | Search repositories, users | | 📊 Dataset | Query research datasets by project | @@ -274,6 +275,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### Notification Management + +```bash +# List unread notifications for the authenticated user +gitlink-cli notification +list --status unread --limit 20 + +# List @ mention messages for a specific user +gitlink-cli notification +list --user zhangsan --type atme --status unread + +# Preview and mark selected messages as read +gitlink-cli notification +read --ids 740214,740213 --dry-run +gitlink-cli notification +read --ids 740214,740213 --yes + +# Preview and mark all unread system notifications as read +gitlink-cli notification +read --type notification --all-unread --dry-run + +# Preview and delete selected messages +gitlink-cli notification +delete --ids 740214,740213 --dry-run + +# Send an @ mention message for an Issue, PR, or Journal target +gitlink-cli notification +send-atme --receivers alice,bob \ + --atmeable-type Issue --atmeable-id 123 --dry-run +``` + ### Wiki Management ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..df558f6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,6 +113,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 🔔 通知 | 列出消息、标记已读、删除消息、发送 @ 提及 | | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | @@ -285,6 +286,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### 通知管理 + +```bash +# 列出当前认证用户的未读通知 +gitlink-cli notification +list --status unread --limit 20 + +# 列出指定用户的 @ 我消息 +gitlink-cli notification +list --user zhangsan --type atme --status unread + +# 预览并将指定消息标记为已读 +gitlink-cli notification +read --ids 740214,740213 --dry-run +gitlink-cli notification +read --ids 740214,740213 --yes + +# 预览将全部未读系统通知标记为已读 +gitlink-cli notification +read --type notification --all-unread --dry-run + +# 预览删除指定消息 +gitlink-cli notification +delete --ids 740214,740213 --dry-run + +# 为 Issue、PR 或 Journal 目标发送 @ 提及消息 +gitlink-cli notification +send-atme --receivers alice,bob \ + --atmeable-type Issue --atmeable-id 123 --dry-run +``` + ### Wiki 管理 ```bash diff --git a/doc/changes/notification-shortcuts.md b/doc/changes/notification-shortcuts.md new file mode 100644 index 0000000..75427f2 --- /dev/null +++ b/doc/changes/notification-shortcuts.md @@ -0,0 +1,82 @@ +# Notification shortcuts + +## Background + +GitLink exposes user messages and notifications through the messages API. The +existing `gitlink-notification-digest` Skill had to call Raw API paths directly +to list notifications and mark messages as read. That made agent workflows more +fragile and forced users to remember GitLink's "messages" terminology. + +This change adds a first-class `notification` shortcut group. + +## New shortcuts + +- `notification +list` lists messages for the authenticated user or a specified + user, with type/status/page/limit filters. +- `notification +read` marks selected messages as read, or marks all unread + messages of a selected type as read. +- `notification +delete` deletes selected messages. +- `notification +send-atme` sends @ mention messages for `Journal`, `Issue`, or + `PullRequest` targets. + +## Safety model + +Read-only listing runs directly: + +```bash +gitlink-cli notification +list --status unread --limit 20 +``` + +Remote write operations require explicit confirmation and support dry-run +previews: + +```bash +gitlink-cli notification +read --ids 740214,740213 --dry-run +gitlink-cli notification +read --ids 740214,740213 --yes +``` + +```bash +gitlink-cli notification +delete --ids 740214,740213 --dry-run +gitlink-cli notification +delete --ids 740214,740213 --yes +``` + +```bash +gitlink-cli notification +send-atme --receivers alice,bob \ + --atmeable-type Issue --atmeable-id 123 --dry-run +``` + +`notification +read --all-unread` maps to GitLink's `ids: [-1]` convention. +The delete command does not expose `--all-unread` to avoid accidental broad +deletion. + +## Documentation updates + +- README and README.zh-CN include notification usage examples. +- `skills/gitlink-notification/` documents the new shortcut group. +- `skills/gitlink-notification-digest` now prefers `notification +list` and + `notification +read` instead of Raw API calls. +- The Skills overview lists the new notification Skill. + +## Tests + +Unit tests cover: + +- list endpoint path, filters, pagination, and current-user fallback; +- dry-run behavior for all write operations; +- confirmation guard without `--yes`; +- read payload construction for selected IDs and all unread messages; +- delete payload construction; +- send-atme payload construction and validation; +- invalid argument handling before remote calls. + +Suggested verification: + +```bash +go test ./shortcuts/notification +``` + +Full project verification: + +```bash +go test ./... +``` diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..1b81230 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -47,6 +47,11 @@ "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", "cmd.issue.view.short": "View issue details", + "cmd.notification.delete.short": "Delete user messages", + "cmd.notification.list.short": "List user messages and notifications", + "cmd.notification.read.short": "Mark user messages as read", + "cmd.notification.send_atme.short": "Send @ mention messages", + "cmd.notification.short": "Notification and message operations", "cmd.org.create.short": "Create an organization", "cmd.org.info.short": "Show organization details", "cmd.org.list.short": "List organizations", @@ -112,6 +117,7 @@ "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", + "error.notification.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", @@ -173,6 +179,16 @@ "flag.issue.title": "Issue title", "flag.lang": "Display language", "flag.limit": "Items per page", + "flag.notification.all_unread": "Mark all unread messages of the selected type as read", + "flag.notification.atmeable_id": "@ message target object ID", + "flag.notification.atmeable_type": "@ message target type: Journal, Issue, or PullRequest", + "flag.notification.dry_run": "Preview the message request without changing remote state", + "flag.notification.ids": "Comma-separated message IDs", + "flag.notification.receivers": "Comma-separated receiver logins", + "flag.notification.status": "Filter status: unread, read, 1, or 2", + "flag.notification.type": "Message type: notification or atme", + "flag.notification.user": "Target user login (defaults to the authenticated user)", + "flag.notification.yes": "Confirm the remote message operation", "flag.org.id": "Organization ID", "flag.org.id_or_login": "Organization ID or login", "flag.org.name": "Organization name", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..00d5f3b 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -47,6 +47,11 @@ "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", "cmd.issue.view.short": "查看议题详情", + "cmd.notification.delete.short": "删除用户消息", + "cmd.notification.list.short": "列出用户消息和通知", + "cmd.notification.read.short": "将用户消息标记为已读", + "cmd.notification.send_atme.short": "发送 @ 提及消息", + "cmd.notification.short": "通知与消息操作", "cmd.org.create.short": "创建组织", "cmd.org.info.short": "显示组织详情", "cmd.org.list.short": "列出组织", @@ -112,6 +117,7 @@ "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", + "error.notification.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", @@ -173,6 +179,16 @@ "flag.issue.title": "议题标题", "flag.lang": "显示语言", "flag.limit": "每页条目数", + "flag.notification.all_unread": "将所选类型的全部未读消息标记为已读", + "flag.notification.atmeable_id": "@ 消息目标对象 ID", + "flag.notification.atmeable_type": "@ 消息目标类型:Journal、Issue 或 PullRequest", + "flag.notification.dry_run": "预览消息请求,不修改远端状态", + "flag.notification.ids": "消息 ID,多个用英文逗号分隔", + "flag.notification.receivers": "接收者登录名,多个用英文逗号分隔", + "flag.notification.status": "筛选状态:unread、read、1 或 2", + "flag.notification.type": "消息类型:notification 或 atme", + "flag.notification.user": "目标用户登录名(默认为当前认证用户)", + "flag.notification.yes": "确认执行远端消息操作", "flag.org.id": "组织 ID", "flag.org.id_or_login": "组织 ID 或登录名", "flag.org.name": "组织名称", diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go new file mode 100644 index 0000000..539345d --- /dev/null +++ b/shortcuts/notification/notification.go @@ -0,0 +1,334 @@ +// Package notification implements GitLink user message shortcuts. +package notification + +import ( + "fmt" + "net/url" + "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")} + + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.notification.list.short"), + 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"}, + }, + Run: runList, + }, + { + Name: "read", + Description: tr.T("cmd.notification.read.short"), + 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"}, + }, + Run: runRead, + }, + { + Name: "delete", + Description: tr.T("cmd.notification.delete.short"), + 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"}, + }, + Run: runDelete, + }, + { + Name: "send-atme", + Description: tr.T("cmd.notification.send_atme.short"), + 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"}, + }, + Run: runSendAtme, + }, + } +} + +func runList(ctx *common.RuntimeContext) error { + user, err := resolveUser(ctx) + 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) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runRead(ctx *common.RuntimeContext) error { + user, err := resolveUser(ctx) + if err != nil { + return err + } + payload, err := readPayload(ctx) + if err != nil { + return err + } + 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) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runDelete(ctx *common.RuntimeContext) error { + user, err := resolveUser(ctx) + if err != nil { + return err + } + ids, err := parseIDs(ctx.Arg("ids")) + if err != nil { + return err + } + 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) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runSendAtme(ctx *common.RuntimeContext) error { + user, err := resolveUser(ctx) + if err != nil { + return err + } + payload, err := sendAtmePayload(ctx) + if err != nil { + return err + } + path := messagesPath(user) + if ctx.Arg("dry-run") == "true" { + return writeDryRun(ctx, "send_atme", "POST", path, user, payload) + } + 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) + 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{}{ + "type": "atme", + "receivers_login": receivers, + "atmeable_type": atmeableType, + "atmeable_id": atmeableID, + }, nil +} + +func writeDryRun(ctx *common.RuntimeContext, action, method, path, user string, payload map[string]interface{}) error { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": action, + "method": method, + "path": path, + "user": user, + "payload": 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 { + return "" + } + if login, ok := data["login"].(string); ok { + return login + } + return "" +} + +func parseIDs(raw string) ([]int, error) { + return parseIntList(raw, "--ids", true) +} + +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() +} diff --git a/shortcuts/notification/notification_test.go b/shortcuts/notification/notification_test.go new file mode 100644 index 0000000..ff6776a --- /dev/null +++ b/shortcuts/notification/notification_test.go @@ -0,0 +1,391 @@ +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/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 { + t.Helper() + shortcut := findShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Format: "json", + Args: args, + Tr: i18n.Default(), + } + return shortcut.Run(ctx) +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write JSON: %v", err) + } +} + +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) + } + return payload +} + +func assertPath(t *testing.T, r *http.Request, method, path string) { + 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) + } +} + +// --- 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 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{}) + if !ok { + return nil + } + ints := make([]int, 0, len(values)) + for _, value := range values { + if number, ok := value.(float64); ok { + ints = append(ints, int(number)) + } + } + return ints +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..7d0052b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -16,6 +16,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" @@ -36,53 +37,55 @@ 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), - "profile": profile.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(), - "dataset": dataset.Shortcuts(tr), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), - "health": health.Shortcuts(tr), - "ignore": ignore.Shortcuts(), - "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(tr), + "pipeline": pipeline.Shortcuts(), + "pr": pr.Shortcuts(tr), + "profile": profile.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(), + "dataset": dataset.Shortcuts(tr), + "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), + "health": health.Shortcuts(tr), + "ignore": ignore.Shortcuts(), + "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"), - "profile": tr.T("cmd.profile.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", - "dataset": tr.T("cmd.dataset.short"), - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", - "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), - "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": tr.T("cmd.notification.short"), + "pipeline": "Pipeline operations", + "pr": tr.T("cmd.pr.short"), + "profile": tr.T("cmd.profile.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", + "dataset": tr.T("cmd.dataset.short"), + "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page management", + "health": "Project health data collection", + "ignore": tr.T("cmd.ignore.short"), + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..dc72227 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "ci", "workflow", + "org", "user", "notification", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", } diff --git a/skills/README.md b/skills/README.md index d507074..2418415 100644 --- a/skills/README.md +++ b/skills/README.md @@ -94,6 +94,8 @@ skills/ ├── gitlink-user/ # 用户管理 │ ├── SKILL.md # 用户操作指南 │ └── references/ # 用户命令参考文档 +├── gitlink-notification/ # 通知与消息管理 +│ └── SKILL.md # 消息查看、标记已读、删除和 @ 提及 ├── gitlink-org/ # 组织管理 │ ├── SKILL.md # 组织操作指南 │ └── references/ # 组织命令参考文档 @@ -140,6 +142,7 @@ skills/ |-------|------|----------| | **gitlink-search** | 搜索功能 | `search +repos`, `search +users` | | **gitlink-user** | 用户管理 | `user +me`, `user +info` | +| **gitlink-notification** | 通知与消息管理 | `notification +list`, `notification +read`, `notification +delete` | | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | diff --git a/skills/gitlink-notification-digest/EXAMPLES.md b/skills/gitlink-notification-digest/EXAMPLES.md index 7c52285..5884aa1 100644 --- a/skills/gitlink-notification-digest/EXAMPLES.md +++ b/skills/gitlink-notification-digest/EXAMPLES.md @@ -4,21 +4,17 @@ **日期**:2026-06-03 **用户**:lindiwen23 -**CLI 版本**:gitlink-cli 0.1.18 +**CLI 版本**:支持 `notification` shortcut 的 gitlink-cli ### 执行流程 ```bash -# Step 1: 获取用户名 -gitlink-cli auth status -# → Logged in as lindiwen23 - # Step 2: 获取未读通知(status=1) -gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json +gitlink-cli notification +list --status unread --limit 20 --format json # → 7 条未读,unread_notification=7, unread_atme=0 # Step 3: 获取已读通知(用于趋势分析和回顾) -gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json +gitlink-cli notification +list --status read --limit 20 --format json # → 21 条已读 # Step 4: 分类统计、生成摘要报告 @@ -31,9 +27,8 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" | 未读通知 | 7 条 | | @我未读 | 0 条 | | 总通知 | 28 条(7 未读 + 21 已读) | -| 不存在命令 | `gitlink-cli notification`(整个子命令不存在) | -| 实际 API | `GET /api/users/{owner}/messages.json` | -| CLI Bug | `api` 路径以 `/` 开头会被解析为本地文件路径 | +| 推荐命令 | `gitlink-cli notification +list` | +| 标记已读 | `gitlink-cli notification +read --ids ... --dry-run/--yes` | ### 原始 API 返回(未读 7 条) @@ -176,12 +171,11 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" ### 经验总结 -1. **`gitlink-cli notification` 命令不存在**:GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API -2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语 -3. **CLI 路径 Bug**:`gitlink-cli api` 的 PATH 参数以 `/` 开头会被解析为本地文件路径,必须去掉前导 `/` -4. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 -5. **没有批量已读 API**:标记已读需逐条调用 `POST users/{owner}/messages/{id}/read` -6. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 +1. **优先使用 `notification` shortcut**:列表、标记已读、删除和发送 @ 消息均已有封装 +2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语,shortcut 已屏蔽路径细节 +3. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 +4. **标记已读是写操作**:必须先 `--dry-run`,用户确认后再 `--yes` +5. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 --- @@ -194,22 +188,17 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" ``` 工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件 -工具调用 2: Bash → gitlink-cli auth status ← 获取用户名 -工具调用 3: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" - --query "status=1&limit=20" --format json ← 获取未读 -工具调用 4: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" - --query "status=2&limit=20" --format json ← 获取已读(趋势分析) -工具调用 5: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" - --query "limit=20" --format json ← 获取全部(总计统计) +工具调用 2: Bash → gitlink-cli notification +list --status unread --limit 20 --format json +工具调用 3: Bash → gitlink-cli notification +list --status read --limit 20 --format json +工具调用 4: Bash → gitlink-cli notification +list --limit 20 --format json ``` ### Agent 决策过程 -Agent **正确遵循了 skill v2.0.0 的工作流**: +Agent **正确遵循了 skill v3.0.0 的工作流**: 1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数 -2. 用 `auth status` 获取当前用户 `lindiwen23` -3. 使用 Raw API(路径无前导 `/`)获取未读、已读、全部三类数据 +2. 使用 `notification +list` 获取未读、已读、全部三类数据 4. 按 `source` 字段分类:`ProjectPullRequest` → P2,`ProjectPraised`/`ProjectMemberJoined` → P3 5. 按输出模板生成结构化报告,含所有七个章节 6. 主动询问是否需要标记 P3 通知为已读 @@ -268,9 +257,9 @@ Agent **正确遵循了 skill v2.0.0 的工作流**: ### 验证结论 -✅ skill v2.0.0 验证通过: -- Agent 正确使用了 `gitlink-cli api` 而非不存在的 `gitlink-cli notification` -- Agent 路径没有以 `/` 开头,避开了 CLI 路径解析 Bug +✅ skill v3.0.0 验证通过: +- Agent 正确使用了 `gitlink-cli notification +list` 获取消息列表 +- Agent 正确使用了 `gitlink-cli notification +read --dry-run` 预览标记已读操作 - Agent 按 `source` 枚举值正确分类,识别出 `PullReuqestAtme` 拼写异常 - Agent 正确区分了 P0/P1/P2/P3 优先级 - Agent 使用 `unread_notification`/`unread_atme` 顶层字段快速统计 @@ -283,22 +272,22 @@ Agent **正确遵循了 skill v2.0.0 的工作流**: | 场景 | 检测方式 | 处理 | |------|----------|------| -| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` | -| API 返回 HTML 而非 JSON | 响应以 `` 开头 | 去掉路径前导 `/` 重试 | -| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` | -| 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 | +| `notification +list` 失败 | 查看错误信息 | 先确认已登录,再运行 `gitlink-cli notification +list --format json` | +| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--page 2` | +| 用户名不确定 | shortcut 自动解析当前用户失败 | 先执行 `gitlink-cli auth status` | | 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" | --- ## 版本兼容性说明 -本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更: +本 skill v3.0.0 基于新增的 `notification` shortcut 编写。关键变更: | 版本 | `notification` 子命令 | 实际 API | 标记已读 | |------|----------------------|----------|----------| | v1.0.0 | `notification +list`(虚构) | 不存在 | `notification +read-all`(虚构) | | v2.0.0 | 无此子命令 | `GET /api/users/{owner}/messages.json` | `POST /api/users/{owner}/messages/{id}/read` | +| v3.0.0 | `notification +list` | 由 shortcut 封装 messages API | `notification +read --ids ... --dry-run/--yes` | 当 CLI 版本更新后,重新验证可用命令: ```bash diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index 72cf9df..6edc247 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -32,17 +32,9 @@ metadata: ## ⚠️ 关键注意事项 -### CLI 路径处理 Bug +### CLI Shortcut 优先 -**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。 - -```bash -# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/... -gitlink-cli api GET /users/me - -# ✅ 正确 — 去掉前导 / -gitlink-cli api GET "users/{owner}/messages.json" -``` +通知摘要优先使用 `notification` shortcut,不再直接拼接 Raw API 路径。只有 shortcut 未覆盖的新接口才回退到 `gitlink-cli api`。 ### 术语对照 @@ -54,33 +46,33 @@ GitLink 平台用「**消息**」(messages)而不是「通知」(notificat ### Step 1:获取通知列表 -使用 Raw API 调用 `/api/users/{owner}/messages.json`: +使用 `notification +list` 获取消息列表: ```bash # 获取未读通知(status=1 表示未读,2 表示已读) -gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json +gitlink-cli notification +list --status unread --limit 20 --format json # 获取全部通知(含已读) -gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json +gitlink-cli notification +list --limit 20 --format json # 分页获取 -gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&page=2&limit=20" --format json +gitlink-cli notification +list --status unread --page 2 --limit 20 --format json # 按类型过滤 # type=notification 系统消息(仓库动态、PR、Issue 等) # type=atme @我消息 -gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json +gitlink-cli notification +list --type atme --status unread --limit 20 --format json ``` **参数说明:** | 参数 | 位置 | 说明 | |------|------|------| -| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) | -| `status` | Query | 1=未读,2=已读,不传=全部 | -| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 | -| `page` | Query | 页码(默认 1) | -| `limit` | Query | 每页条数(默认 20) | +| `--user` | Flag | 目标用户登录名,不传时自动使用当前认证用户 | +| `--status` | Flag | `unread`/`1`=未读,`read`/`2`=已读,不传=全部 | +| `--type` | Flag | `notification`=系统消息,`atme`=@我消息,不传=全部 | +| `--page` | Flag | 页码(默认 1) | +| `--limit` | Flag | 每页条数(默认 20) | **响应结构:** @@ -193,17 +185,18 @@ gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&li ### Step 4:标记已读(可选,需确认) ```bash -# 标记单条已读 -gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json +# 预览标记指定消息为已读 +gitlink-cli notification +read --ids ,, --dry-run --format json -# 批量标记已读 — 逐条调用,GitLink 暂无批量已读 API -for id in ; do - gitlink-cli api POST "users/{owner}/messages/$id/read" --format json -done +# 确认执行 +gitlink-cli notification +read --ids ,, --yes --format json + +# 预览将全部未读系统通知标记为已读 +gitlink-cli notification +read --type notification --all-unread --dry-run --format json ``` > ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。 -> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。 +> ⚠️ **必须先 dry-run,再由用户确认后加 `--yes` 执行。** --- @@ -283,7 +276,7 @@ done - 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 如需标记 P3 通知为已读,我可以逐条执行: -`gitlink-cli api POST "users/{owner}/messages/{id}/read"` +`gitlink-cli notification +read --ids --dry-run` ``` --- @@ -294,9 +287,8 @@ done |------|----------| | 无未读通知 | 输出"🎉 所有通知已处理完毕" | | 通知数量 > 50 | 分页获取(page 1/2/3),优先分析最近 50 条 | -| API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 | -| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 | -| 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 | +| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--page 2` 获取 | +| 用户名不确定 | `notification +list` 会自动读取当前认证用户;失败时先执行 `gitlink-cli auth status` | --- @@ -305,7 +297,6 @@ done - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **标记已读为写操作**,执行前必须确认用户意图 - ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读 -- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**(CLI Bug) - ⚠️ **GitLink 用「消息(messages)」而非「通知(notifications)」** - ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配 -- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"` +- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2` diff --git a/skills/gitlink-notification/SKILL.md b/skills/gitlink-notification/SKILL.md new file mode 100644 index 0000000..3e58c36 --- /dev/null +++ b/skills/gitlink-notification/SKILL.md @@ -0,0 +1,109 @@ +--- +name: gitlink-notification +version: 1.0.0 +description: "通知与消息管理:查看 GitLink 通知、标记已读、删除消息、发送 @ 提及消息。当用户需要查看或管理站内消息/通知时触发。" +metadata: + requires: + bins: ["gitlink-cli"] +--- + +# gitlink-notification(通知与消息管理) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和安全规则。** +**CRITICAL — 标记已读、删除消息和发送 @ 消息都是写操作,执行前必须先 dry-run 并确认用户意图。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。 + +## 功能概述 + +GitLink 平台的通知在 API 中称为 messages。本 Skill 使用 `notification` shortcut 管理用户消息: + +| 命令 | 用途 | 是否写操作 | +|------|------|------------| +| `notification +list` | 列出用户消息和通知 | 否 | +| `notification +read` | 将消息标记为已读 | 是 | +| `notification +delete` | 删除消息 | 是 | +| `notification +send-atme` | 发送 @ 提及消息 | 是 | + +## 常用命令 + +```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 +read --ids 740214,740213 --dry-run --format json + +# 确认标记指定消息为已读 +gitlink-cli notification +read --ids 740214,740213 --yes --format json + +# 预览将全部未读系统通知标记为已读 +gitlink-cli notification +read --type notification --all-unread --dry-run --format json + +# 预览删除指定消息 +gitlink-cli notification +delete --ids 740214,740213 --dry-run --format json + +# 发送 @ 提及消息,先 dry-run +gitlink-cli notification +send-atme --receivers alice,bob \ + --atmeable-type Issue --atmeable-id 123 --dry-run --format json +``` + +## 参数 + +### `notification +list` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--user, -u` | 否 | 目标用户登录名,默认使用当前认证用户 | +| `--type, -t` | 否 | 消息类型:`notification` 或 `atme` | +| `--status, -s` | 否 | 状态:`unread`/`1` 或 `read`/`2` | +| `--page, -p` | 否 | 页码,默认 `1` | +| `--limit, -l` | 否 | 每页数量,默认 `20` | + +### `notification +read` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--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)