Merge PR #315: feat(notification): add message notification shortcuts

# Conflicts:
#	README.md
#	shortcuts/notification/notification.go
#	shortcuts/notification/notification_test.go
#	shortcuts/register.go
#	skills/README.md
#	skills/gitlink-notification-digest/EXAMPLES.md
#	skills/gitlink-notification/SKILL.md
This commit is contained in:
wbtiger 2026-07-14 22:50:04 +08:00
commit b01084d6e5
13 changed files with 1234 additions and 519 deletions

171
README.md
View File

@ -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 <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### 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 <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) |

View File

@ -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

View File

@ -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 ./...
```

View File

@ -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",

View File

@ -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": "组织名称",

View File

@ -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()
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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",
}

View File

@ -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 |

View File

@ -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在 <b>jiangtx/gitlink-cli</b> 提交了一个合并请求:<b>label 模块新建</b>",
"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在 <b>jiangtx/gitlink-cli</b> 提交了一个合并请求:<b>pr 域补全</b>",
"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在 <b>jiangtx/gitlink-cli</b> 提交了一个合并请求:<b>repo 域补全</b>",
"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在 <b>jiangtx/gitlink-cli</b> 提交了一个合并请求:<b>基础设施修复</b>",
"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": "<b>CWQ</b> 点赞了你管理的仓库 <b>CWQ/Aether_Lens_System-v0.0.1</b>",
"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": "<b>Somebird</b> 已加入项目 <b>CWQ/Aether_Lens_System-v0.0.1</b>",
"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": "<b>Somebird</b> 点赞了你管理的仓库 <b>CWQ/Aether_Lens_System-v0.0.1</b>",
"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` | 项目新 PRjiangtx/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
```

View File

@ -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 <id1>,<id2>,<id3> --dry-run --format json
# 批量标记已读 — 逐条调用GitLink 暂无批量已读 API
for id in <id1> <id2> <id3>; do
gitlink-cli api POST "users/{owner}/messages/$id/read" --format json
done
# 确认执行
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --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 <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`

View File

@ -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)