forked from Gitlink/gitlink-cli
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:
commit
b01084d6e5
171
README.md
171
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 <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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ./...
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "组织名称",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` | 项目新 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
|
||||
```
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue