diff --git a/README.md b/README.md index fa1d33d..5ff8ea7 100644 --- a/README.md +++ b/README.md @@ -104,19 +104,20 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| | 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | -| 🐛 Issue | Create, update, close, batch close, comment on issues | +| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | -| 🌿 Branch | List, create, delete, restore, set default, protect, unprotect branches | +| 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 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 | -| 📄 File | View, search, create, update, and delete repository files without cloning | | 👤 User | View user profiles and info | | 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | @@ -274,6 +275,52 @@ 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 +# List wiki pages (table of contents) +gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 + +# View a wiki page by page name +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home + +# Create a wiki page +gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ + -n getting-started -t "Getting Started" -c "# Getting Started Guide" + +# Update a wiki page title and/or content +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content" + +# Delete a wiki page +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page +``` + ### Member Management ```bash @@ -323,13 +370,17 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 # Batch close issues from a CSV file gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv +# Preview batch metadata update by API issue IDs +# Note: --ids uses API issue IDs, not web URL issue numbers. +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run + +# Destructive batch delete requires both dry-run first and --yes for real execution +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes + # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" -# List issue journals or comment activity -gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --page 1 --limit 50 -gitlink-cli issue +activity --owner Gitlink --repo forgeplus --number 123 --category comment - # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -394,14 +445,6 @@ gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42 # View changed files gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 -# List commits of a pull request -gitlink-cli pr +commits --owner Gitlink --repo forgeplus -i 42 - -# Pre-flight: can a merge request be created between two branches? -gitlink-cli pr +check-merge --owner Gitlink --repo forgeplus --head develop --base master -# Cross-fork variant -gitlink-cli pr +check-merge --owner Gitlink --repo forgeplus --head feat/x --base master --fork-project-id 12345 - # List PR patchset versions gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42 @@ -419,24 +462,14 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved ### Branch Management ```bash -# List branches, including deleted branches when needed -gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature -gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted +# List branches +gitlink-cli branch +list --owner Gitlink --repo forgeplus -# List all branches without pagination -gitlink-cli branch +all --owner Gitlink --repo forgeplus +# Create a branch +gitlink-cli branch +create --name feature/new-feature -# Create a branch, with dry-run preview -gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run - -# Delete a branch, with dry-run preview -gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run - -# Set default branch -gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run - -# Restore a deleted branch -gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run +# Delete a branch +gitlink-cli branch +delete --name feature/old-feature # Protect a branch gitlink-cli branch +protect --name main @@ -478,30 +511,6 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i ``` -### Gitea Actions - -```bash -# List workflow files (.gitea/workflows) -gitlink-cli action +list --owner Gitlink --repo forgeplus - -# List runs of a workflow -gitlink-cli action +runs --owner Gitlink --repo forgeplus -w ci.yml - -# Trigger a workflow run on a branch -gitlink-cli action +run --owner Gitlink --repo forgeplus -w ci.yml -r master - -# Rerun a whole run, or a single job -gitlink-cli action +rerun --owner Gitlink --repo forgeplus -i 6 -gitlink-cli action +job-rerun --owner Gitlink --repo forgeplus -i 6 -j build - -# Raw logs of a workflow job -gitlink-cli action +logs --owner Gitlink --repo forgeplus -i 6 -j 0 - -# Enable or disable a workflow -gitlink-cli action +disable --owner Gitlink --repo forgeplus -w ci.yml -gitlink-cli action +enable --owner Gitlink --repo forgeplus -w ci.yml -``` - ### Pipeline Operations ```bash @@ -567,24 +576,6 @@ gitlink-cli profile +activity gitlink-cli profile +contribution --user zhangsan --year 2025 ``` -### User Account - -```bash -# Show current authenticated user -gitlink-cli user +me - -# List SSH public keys -gitlink-cli user +keys - -# Add an SSH public key from inline content or a file -gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." -gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub -gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub - -# Delete an SSH public key -gitlink-cli user +delete-key --id 123 -``` - ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -710,36 +701,6 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye > published OpenAPI contract but are not yet deployed on production (they return > 404 there); they will work once the platform enables them. -### File Operations - -`file` reads and writes repository file contents without cloning — ideal for -AI agents that need to read or patch a single file. For directory listings and -README viewing, see `repo +tree` and `repo +readme`. - -```bash -# View a file (--raw prints only the decoded content, for piping) -gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md -gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md - -# Search files by name -gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller - -# Create / update a file (content inline or from a local file) -gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# Note" -b master -m "add note" -gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master - -# Commit to a new branch created from --branch -gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs - -# Delete a file -gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" - -# Multiple file operations in a single commit (JSON spec) -# spec.json: [{"action_type":"create","file_path":"a.txt","content":"A"}, -# {"action_type":"delete","file_path":"old.txt"}] -gitlink-cli file +batch --owner me --repo proj -s spec.json -b master -m "batch ops" -``` - ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: @@ -802,7 +763,7 @@ See [skills/README.md](./skills/README.md) for details. |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | -| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | +| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | @@ -811,7 +772,7 @@ See [skills/README.md](./skills/README.md) for details. | `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | | `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) | -| `gitlink-user` | User management (profile info, SSH keys, etc.) | +| `gitlink-user` | User management (profile info, etc.) | | `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) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 987dcab..2bbf17b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,6 +113,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 🔔 通知 | 列出消息、标记已读、删除消息、发送 @ 提及 | | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | @@ -286,6 +287,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 d152eb3..f480478 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -49,6 +49,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", @@ -114,6 +119,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", @@ -176,6 +182,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 56c5cf5..ba8b101 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -49,6 +49,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": "列出组织", @@ -114,6 +119,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": "批处理请求失败后继续执行后续请求", @@ -176,6 +182,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 index 853aa25..539345d 100644 --- a/shortcuts/notification/notification.go +++ b/shortcuts/notification/notification.go @@ -1,3 +1,4 @@ +// Package notification implements GitLink user message shortcuts. package notification import ( @@ -7,32 +8,26 @@ import ( "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" ) -var messageTypes = map[string]string{ - "notification": "notification", - "atme": "atme", -} - -var listStatuses = map[string]string{ - "unread": "1", - "read": "2", -} +const allUnreadMessageID = -1 +// Shortcuts returns user notification/message shortcuts. func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := i18n.Default() - if len(translators) > 0 && translators[0] != nil { - tr = translators[0] - } + 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{ - {Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}, - {Name: "type", Short: "t", Usage: tr.T("flag.notification.type_all"), Default: "all"}, - {Name: "status", Short: "s", Usage: tr.T("flag.notification.status"), Default: "all"}, + 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"}, }, @@ -42,9 +37,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "read", Description: tr.T("cmd.notification.read.short"), Flags: []common.Flag{ - {Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}, - {Name: "type", Short: "t", Usage: tr.T("flag.notification.type"), Required: true}, - {Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids_read"), Required: true}, + 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, }, @@ -52,25 +50,41 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "delete", Description: tr.T("cmd.notification.delete.short"), Flags: []common.Flag{ - {Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}, - {Name: "type", Short: "t", Usage: tr.T("flag.notification.type"), Required: true}, + 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 := resolveUserLogin(ctx) + user, err := resolveUser(ctx) if err != nil { return err } - query, err := listQuery(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPIWithQuery("GET", messagesPath(user), query) + 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 } @@ -78,11 +92,22 @@ func runList(ctx *common.RuntimeContext) error { } func runRead(ctx *common.RuntimeContext) error { - user, payload, err := messagePayload(ctx, true) + user, err := resolveUser(ctx) if err != nil { return err } - env, err := ctx.CallAPI("POST", messagesPath(user)+"/read", payload) + 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 } @@ -90,150 +115,220 @@ func runRead(ctx *common.RuntimeContext) error { } func runDelete(ctx *common.RuntimeContext) error { - user, payload, err := messagePayload(ctx, false) + user, err := resolveUser(ctx) if err != nil { return err } - env, err := ctx.CallAPI("DELETE", messagesPath(user), payload) + 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 messagesPath(user string) string { - return fmt.Sprintf("/users/%s/messages", url.PathEscape(user)) +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 listQuery(ctx *common.RuntimeContext) (url.Values, error) { - page, err := positiveInt(defaultString(ctx.Arg("page"), "1"), "page") +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 } - limit, err := positiveInt(defaultString(ctx.Arg("limit"), "20"), "limit") - if err != nil { - return nil, err - } - query := url.Values{} - query.Set("page", strconv.Itoa(page)) - query.Set("limit", strconv.Itoa(limit)) - if typ, err := normalizeOptionalType(ctx.Arg("type")); err != nil { - return nil, err - } else if typ != "" { - query.Set("type", typ) - } - if status, err := normalizeStatus(ctx.Arg("status")); err != nil { - return nil, err - } else if status != "" { - query.Set("status", status) - } - return query, nil + return messageActionPayload(ctx.Arg("type"), ids), nil } -func messagePayload(ctx *common.RuntimeContext, allowAllUnread bool) (string, map[string]interface{}, error) { - user, err := resolveUserLogin(ctx) - if err != nil { - return "", nil, err - } - typ, err := normalizeRequiredType(ctx.Arg("type")) - if err != nil { - return "", nil, err - } - ids, err := parseIDs(ctx.Arg("ids"), allowAllUnread) - if err != nil { - return "", nil, err - } - return user, map[string]interface{}{ - "type": typ, +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 resolveUserLogin(ctx *common.RuntimeContext) (string, error) { +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 "", fmt.Errorf("resolve current user: %w", err) + 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 "", fmt.Errorf("resolve current user: unexpected response") + return "" } - login, _ := data["login"].(string) - if strings.TrimSpace(login) == "" { - return "", fmt.Errorf("resolve current user: login is missing") + if login, ok := data["login"].(string); ok { + return login } - return strings.TrimSpace(login), nil + return "" } -func normalizeOptionalType(value string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" || value == "all" { - return "", nil - } - return normalizeRequiredType(value) +func parseIDs(raw string) ([]int, error) { + return parseIntList(raw, "--ids", true) } -func normalizeRequiredType(value string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if typ, ok := messageTypes[value]; ok { - return typ, nil - } - return "", fmt.Errorf("invalid --type %q: use notification or atme", value) -} - -func normalizeStatus(value string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" || value == "all" { - return "", nil - } - if status, ok := listStatuses[value]; ok { - return status, nil - } - return "", fmt.Errorf("invalid --status %q: use unread, read, or all", value) -} - -func parseIDs(value string, allowAllUnread bool) ([]int, error) { - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - seen := map[int]bool{} +func parseIntList(raw, flag string, positiveOnly bool) ([]int, error) { + parts := strings.Split(raw, ",") + values := make([]int, 0, len(parts)) for _, part := range parts { - raw := strings.TrimSpace(part) - if raw == "" { + part = strings.TrimSpace(part) + if part == "" { continue } - id, err := strconv.Atoi(raw) - if err != nil || id == 0 || id < -1 { - return nil, fmt.Errorf("invalid --ids value %q: use positive integer IDs", raw) + value, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid %s value %q: use comma-separated integers", flag, part) } - if id == -1 && !allowAllUnread { - return nil, fmt.Errorf("invalid --ids value -1: delete requires explicit message IDs") + if positiveOnly && value <= 0 { + return nil, fmt.Errorf("invalid %s value %q: use a positive integer", flag, part) } - if seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) + values = append(values, value) } - if len(ids) == 0 { - return nil, fmt.Errorf("required flag --ids is empty") + if len(values) == 0 { + return nil, fmt.Errorf("%s must include at least one id", flag) } - return ids, nil + return values, nil } -func positiveInt(value, name string) (int, error) { - parsed, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || parsed <= 0 { - return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value) +func parsePositiveInt(raw, flag string) (int, error) { + values, err := parseIntList(raw, flag, true) + if err != nil { + return 0, err } - return parsed, nil + if len(values) != 1 { + return 0, fmt.Errorf("%s must include exactly one id", flag) + } + return values[0], nil } -func defaultString(value, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback +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) + } } - return value + 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 index ad2bbd9..ff6776a 100644 --- a/shortcuts/notification/notification_test.go +++ b/shortcuts/notification/notification_test.go @@ -2,190 +2,44 @@ package notification import ( "encoding/json" - "fmt" "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 TestNotificationListResolvesCurrentUser(t *testing.T) { - requests := 0 - server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { - requests++ - switch requests { - case 1: - assertRequest(t, r, "GET", "/users/me.json") - writeJSON(t, w, map[string]interface{}{"login": "mengz"}) - case 2: - assertRequest(t, r, "GET", "/users/mengz/messages.json") - assertEqual(t, r.URL.Query().Get("type"), "notification") - assertEqual(t, r.URL.Query().Get("status"), "1") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "messages": []interface{}{}}) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - err := runNotificationShortcut(t, server, "list", map[string]string{ - "type": "notification", - "status": "unread", - "page": "2", - "limit": "50", - }) - if err != nil { - t.Fatalf("list shortcut failed: %v", err) - } - assertEqual(t, requests, 2) -} - -func TestNotificationListUsesExplicitUser(t *testing.T) { - server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/users/alice/messages.json") - assertEqual(t, r.URL.Query().Get("page"), "1") - assertEqual(t, r.URL.Query().Get("limit"), "20") - assertEqual(t, r.URL.Query().Get("type"), "") - assertEqual(t, r.URL.Query().Get("status"), "") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "messages": []interface{}{}}) - }) - defer server.Close() - - if err := runNotificationShortcut(t, server, "list", map[string]string{"user": "alice"}); err != nil { - t.Fatalf("list shortcut failed: %v", err) - } -} - -func TestNotificationReadPayload(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, "read", map[string]string{ - "user": "alice", - "type": "atme", - "ids": "1,2,2", - }) - if err != nil { - t.Fatalf("read shortcut failed: %v", err) - } - assertEqual(t, payload["type"], "atme") - assertIntSlice(t, payload["ids"], []int{1, 2}) -} - -func TestNotificationReadAllUnread(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, "read", map[string]string{ - "user": "alice", - "type": "notification", - "ids": "-1", - }) - if err != nil { - t.Fatalf("read shortcut failed: %v", err) - } - assertIntSlice(t, payload["ids"], []int{-1}) -} - -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", - "type": "notification", - "ids": "7,8", - }) - if err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } - assertEqual(t, payload["type"], "notification") - assertIntSlice(t, payload["ids"], []int{7, 8}) -} - -func TestNotificationValidation(t *testing.T) { - server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []struct { - name string - shortcut string - args map[string]string - }{ - {name: "invalid type", shortcut: "list", args: map[string]string{"user": "alice", "type": "other"}}, - {name: "invalid status", shortcut: "list", args: map[string]string{"user": "alice", "status": "maybe"}}, - {name: "invalid page", shortcut: "list", args: map[string]string{"user": "alice", "page": "0"}}, - {name: "invalid ids", shortcut: "read", args: map[string]string{"user": "alice", "type": "atme", "ids": "abc"}}, - {name: "delete all unread rejected", shortcut: "delete", args: map[string]string{"user": "alice", "type": "atme", "ids": "-1"}}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runNotificationShortcut(t, server, tc.shortcut, tc.args); err == nil { - t.Fatal("expected validation error") - } - }) - } -} - -func runNotificationShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { +func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() - shortcut := findNotificationShortcut(t, name) + shortcut := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Format: "json", Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} + Tr: i18n.Default(), } return shortcut.Run(ctx) } -func findNotificationShortcut(t *testing.T, name string) *common.Shortcut { +func findShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + for _, s := range Shortcuts() { + if s.Name == name { + return s } } t.Fatalf("shortcut %q not found", name) return nil } -func newNotificationTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { 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("unexpected request: %s %s", r.Method, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write JSON: %v", err) } } @@ -193,36 +47,345 @@ 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) + t.Fatalf("decode request body: %v", err) } return payload } -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { +func assertPath(t *testing.T, r *http.Request, method, path string) { 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) + 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) } } -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) +// --- 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 assertIntSlice(t *testing.T, got interface{}, want []int) { - t.Helper() - values, ok := got.([]interface{}) +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 { - t.Fatalf("got ids %T, want []interface{}", got) + return nil } - if len(values) != len(want) { - t.Fatalf("got ids length %d, want %d", len(values), len(want)) - } - for i, value := range values { - assertEqual(t, value, float64(want[i])) + 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 1a8340b..7d0052b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,7 +9,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" - "github.com/gitlink-org/gitlink-cli/shortcuts/file" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" @@ -17,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" @@ -37,55 +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), - "file": file.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"), - "file": tr.T("cmd.file.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 9238ebd..464f147 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", "file", "health", "ignore", "wiki", } diff --git a/skills/README.md b/skills/README.md index 16174d9..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/ # 组织命令参考文档 @@ -103,8 +105,6 @@ skills/ │ └── SKILL.md # Pipeline 操作指南 ├── gitlink-wiki/ # Wiki 页面管理 │ └── SKILL.md # Wiki 操作指南 -├── gitlink-file/ # 文件内容操作 -│ └── SKILL.md # 文件操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -129,11 +129,11 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like`, `repo +units`, `repo +set-units` | +| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | -| **gitlink-branch** | 分支管理 | `branch +list`, `branch +all`, `branch +create`, `branch +delete`, `branch +set-default`, `branch +restore` | +| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | ### 辅助 Skills @@ -141,12 +141,12 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-search** | 搜索功能 | `search +repos`, `search +users` | -| **gitlink-user** | 用户管理 | `user +me`, `user +info`, `user +keys`, `user +add-key`, `user +delete-key` | +| **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` | | **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | -| **gitlink-file** | 文件内容操作(无需克隆) | `file +view`, `file +search`, `file +create`, `file +update`, `file +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | diff --git a/skills/gitlink-notification-digest/EXAMPLES.md b/skills/gitlink-notification-digest/EXAMPLES.md new file mode 100644 index 0000000..5884aa1 --- /dev/null +++ b/skills/gitlink-notification-digest/EXAMPLES.md @@ -0,0 +1,295 @@ +# gitlink-notification-digest 使用样例 + +## 样例 1:手动执行通知摘要 + +**日期**:2026-06-03 +**用户**:lindiwen23 +**CLI 版本**:支持 `notification` shortcut 的 gitlink-cli + +### 执行流程 + +```bash +# Step 2: 获取未读通知(status=1) +gitlink-cli notification +list --status unread --limit 20 --format json +# → 7 条未读,unread_notification=7, unread_atme=0 + +# Step 3: 获取已读通知(用于趋势分析和回顾) +gitlink-cli notification +list --status read --limit 20 --format json +# → 21 条已读 + +# Step 4: 分类统计、生成摘要报告 +``` + +### 关键发现 + +| 项目 | 值 | +|------|-----| +| 未读通知 | 7 条 | +| @我未读 | 0 条 | +| 总通知 | 28 条(7 未读 + 21 已读) | +| 推荐命令 | `gitlink-cli notification +list` | +| 标记已读 | `gitlink-cli notification +read --ids ... --dry-run/--yes` | + +### 原始 API 返回(未读 7 条) + +```json +{ + "total_count": 7, + "type": "", + "unread_notification": 7, + "unread_atme": 0, + "messages": [ + { + "id": 740214, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:label 模块新建", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15347", + "source": "ProjectPullRequest", + "created_at": "2026-06-03 00:27:37", "time_ago": "10小时前", + "type": "notification" + }, + { + "id": 740213, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:pr 域补全", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15346", + "source": "ProjectPullRequest", + "created_at": "2026-06-03 00:11:48", "time_ago": "10小时前", + "type": "notification" + }, + { + "id": 740178, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:repo 域补全", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15343", + "source": "ProjectPullRequest", + "created_at": "2026-06-02 23:29:52", "time_ago": "11小时前", + "type": "notification" + }, + { + "id": 740076, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:基础设施修复", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15336", + "source": "ProjectPullRequest", + "created_at": "2026-06-02 16:56:47", "time_ago": "17小时前", + "type": "notification" + }, + { + "id": 740002, "status": 1, + "content": "CWQ 点赞了你管理的仓库 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/caoweiqiong", + "source": "ProjectPraised", + "created_at": "2026-06-02 15:12:55", "time_ago": "19小时前", + "type": "notification" + }, + { + "id": 738181, "status": 1, + "content": "Somebird 已加入项目 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/caoweiqiong/Aether", + "source": "ProjectMemberJoined", + "created_at": "2026-06-01 22:22:07", "time_ago": "1天前", + "type": "notification" + }, + { + "id": 738136, "status": 1, + "content": "Somebird 点赞了你管理的仓库 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/Somebird", + "source": "ProjectPraised", + "created_at": "2026-06-01 20:18:28", "time_ago": "2天前", + "type": "notification" + } + ] +} +``` + +### 分类处理 + +按 `source` 字段分类: + +| source | 含义 | 数量 | 优先级 | +|--------|------|------|--------| +| `ProjectPullRequest` | 项目新 PR(jiangtx/gitlink-cli) | 4 | P2 | +| `ProjectPraised` | 项目被点赞(CWQ/Aether_Lens_System) | 2 | P3 | +| `ProjectMemberJoined` | 新成员加入 | 1 | P3 | + +### 生成的报告 + +```markdown +# 🔔 通知摘要 + +> 生成时间:2026-06-03 10:18 +> 未读通知:7 条 / 总计:28 条 + +--- + +## 一、概要 + +| 类型 | 未读 | 总计 | +|------|------|------| +| 🔴 @提及 | 0 | 0 | +| 🟡 Issue 更新 | 0 | 0 | +| 🟢 PR 更新 | 4 | ~8 | +| 🔵 系统通知 | 3 | ~19 | + +--- + +## 二、需要立即处理(P0) + +🎉 无紧急通知。 + +## 三、今天处理(P1) + +无待处理通知。 + +## 四、本周关注(P2) + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| +| 1 | 🟢 PR | jiangtx/gitlink-cli | label 模块新建 (#15347) | 6/3 00:27 | +| 2 | 🟢 PR | jiangtx/gitlink-cli | pr 域补全 (#15346) | 6/3 00:11 | +| 3 | 🟢 PR | jiangtx/gitlink-cli | repo 域补全 (#15343) | 6/2 23:29 | +| 4 | 🟢 PR | jiangtx/gitlink-cli | 基础设施修复 (#15336) | 6/2 16:56 | + +## 五、可忽略(P3) + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| +| 1 | 🔵 点赞 | CWQ/Aether_Lens_System-v0.0.1 | CWQ 点赞了仓库 | 6/2 15:12 | +| 2 | 🔵 成员 | CWQ/Aether_Lens_System-v0.0.1 | Somebird 加入项目 | 6/1 22:22 | +| 3 | 🔵 点赞 | CWQ/Aether_Lens_System-v0.0.1 | Somebird 点赞了仓库 | 6/1 20:18 | + +## 六、通知趋势 + +| 时间段 | 通知数 | +|--------|--------| +| 今日(6/3) | 2 | +| 昨日(6/2) | 3 | +| 本周(6/1-6/3) | 8 | + +## 操作建议 + +- 建议标记已读:3 条 P3 通知 +- 需要回复/处理:0 条 P0/P1 通知 +``` + +### 经验总结 + +1. **优先使用 `notification` shortcut**:列表、标记已读、删除和发送 @ 消息均已有封装 +2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语,shortcut 已屏蔽路径细节 +3. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 +4. **标记已读是写操作**:必须先 `--dry-run`,用户确认后再 `--yes` +5. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 + +--- + +## 样例 2:通过 Agent 调用 Skill(自动摘要) + +**日期**:2026-06-03 +**调用方式**:`Agent(subagent_type="general-purpose", prompt="请调用 gitlink-notification-digest skill,帮我整理通知。")` + +### Agent 自主执行的命令序列 + +``` +工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件 +工具调用 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 v3.0.0 的工作流**: + +1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数 +2. 使用 `notification +list` 获取未读、已读、全部三类数据 +4. 按 `source` 字段分类:`ProjectPullRequest` → P2,`ProjectPraised`/`ProjectMemberJoined` → P3 +5. 按输出模板生成结构化报告,含所有七个章节 +6. 主动询问是否需要标记 P3 通知为已读 + +共消耗 **35,601 tokens**,**6 次工具调用**,耗时 **51.7 秒**。 + +### Agent 生成的报告 + +```markdown +# 通知摘要 + +> 生成时间:2026-06-03 10:30 +> 未读通知:7 条 / 总计:28 条(含已读 21 条) +> @我未读:0 条 + +## 一、概要 + +| 类型 | 未读 | 总计 | +|------|------|------| +| @提及 | 0 | 0 | +| Issue 更新 | 0 | 0 | +| PR 更新 | 4 | 7 | +| 系统通知 | 3 | 21 | + +## 二、需要立即处理(P0) +无紧急通知。 + +## 三、今天处理(P1) +无待处理通知。 + +## 四、本周关注(P2) +4 条 jiangtx/gitlink-cli 的 PR 需关注 + +## 五、可忽略(P3) +3 条 CWQ/Aether_Lens_System-v0.0.1 的点赞和成员通知 + +## 六、通知趋势 +| 时间段 | 通知数 | +|--------|--------| +| 今日 | 2 | +| 昨日 | 3 | +| 本周 | 7 | + +## 七、近期已读回顾 +| 类型 | 内容 | 时间 | +|------|------|------| +| 加入项目 | 加入 jiangtx/gitlink-cli | 06-01 | +| 成员加入 | wyxttn 加入 yetja/灵枢 | 05-29 | +| PR 合并 | 帮助中心 PR 已通过 | 05-13 | +| 角色变更 | 帮助中心角色改为管理员 | 05-13 | + +## 操作建议 +- 建议标记已读:3 条 P3 通知 +- 需要关注:4 条 P2 通知 +``` + +### 验证结论 + +✅ 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` 顶层字段快速统计 +- Agent 生成了趋势章节和已读回顾章节 +- 报告结构完整,七个章节覆盖全部模板要求 + +--- + +## 异常场景速查 + +| 场景 | 检测方式 | 处理 | +|------|----------|------| +| `notification +list` 失败 | 查看错误信息 | 先确认已登录,再运行 `gitlink-cli notification +list --format json` | +| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--page 2` | +| 用户名不确定 | shortcut 自动解析当前用户失败 | 先执行 `gitlink-cli auth status` | +| 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" | + +--- + +## 版本兼容性说明 + +本 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 +gitlink-cli --help +``` diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index b317c94..bb91e02 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -33,17 +33,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`。 ### 术语对照 @@ -55,33 +47,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) | **响应结构:** @@ -194,17 +186,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` 执行。** --- @@ -284,7 +277,7 @@ done - 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 如需标记 P3 通知为已读,我可以逐条执行: -`gitlink-cli api POST "users/{owner}/messages/{id}/read"` +`gitlink-cli notification +read --ids --dry-run` ``` --- @@ -295,9 +288,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` | --- @@ -306,7 +298,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 index fdde65b..3e58c36 100644 --- a/skills/gitlink-notification/SKILL.md +++ b/skills/gitlink-notification/SKILL.md @@ -1,38 +1,109 @@ --- name: gitlink-notification version: 1.0.0 -description: "User messages: list GitLink messages, mark messages as read, and delete messages." +description: "通知与消息管理:查看 GitLink 通知、标记已读、删除消息、发送 @ 提及消息。当用户需要查看或管理站内消息/通知时触发。" metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli notification --help" --- -# gitlink-notification +# gitlink-notification(通知与消息管理) -Use this skill when an agent needs to inspect or update GitLink user messages. +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和安全规则。** +**CRITICAL — 标记已读、删除消息和发送 @ 消息都是写操作,执行前必须先 dry-run 并确认用户意图。** -## Shortcuts +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。 -| Shortcut | Purpose | -|----------|---------| -| `notification +list` | List user messages | -| `notification +read` | Mark messages as read | -| `notification +delete` | Delete messages | +## 功能概述 -## Examples +GitLink 平台的通知在 API 中称为 messages。本 Skill 使用 `notification` shortcut 管理用户消息: + +| 命令 | 用途 | 是否写操作 | +|------|------|------------| +| `notification +list` | 列出用户消息和通知 | 否 | +| `notification +read` | 将消息标记为已读 | 是 | +| `notification +delete` | 删除消息 | 是 | +| `notification +send-atme` | 发送 @ 提及消息 | 是 | + +## 常用命令 ```bash -gitlink-cli notification +list --type notification --status unread -gitlink-cli notification +list --user Mengz --type atme -gitlink-cli notification +read --type atme --ids 101,102 -gitlink-cli notification +read --type notification --ids -1 -gitlink-cli notification +delete --type notification --ids 101,102 +# 查看当前认证用户的未读通知 +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 ``` -## Safety Notes +## 参数 -- Confirm the target user before using `--user`. -- `notification +list --type all` queries all message types; when `type=all`, avoid assuming `--status` is applied to each backend category in the same way. -- `notification +read --ids -1` marks all unread messages of the selected type as read. -- `notification +delete` requires explicit message IDs and does not accept `-1`. +### `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)