Merge PR #272: feat(message): 补齐通知中心与消息设置管理工作流

# Conflicts:
#	README.md
#	doc/changes/notification-shortcut.md
#	shortcuts/notification/notification.go
#	shortcuts/notification/notification_test.go
#	shortcuts/register.go
#	shortcuts/register_test.go
#	skills/README.md
This commit is contained in:
wbtiger 2026-07-14 22:49:57 +08:00
commit 503d8c5923
11 changed files with 766 additions and 445 deletions

235
README.md
View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
**[中文文档](./README.zh-CN.md)**
@ -78,11 +78,19 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## Why gitlink-cli?
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
@ -96,8 +104,7 @@ 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 |
| 🎯 Milestone | List, create, inspect, summarize, update, close, and reopen milestones |
| 🐛 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 |
@ -106,9 +113,14 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Message Settings | Inspect and update personal message delivery preferences |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔔 Notification | List, read, and delete user messages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 👤 User | View user profiles and info |
| 📊 Profile | User ability, role, major, activity, and contribution statistics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@ -264,6 +276,47 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### 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
```
### Notifications
```bash
# List current user's unread notifications
gitlink-cli notification +list --type notification --status unread
# List @me messages for an explicit user
gitlink-cli notification +list --user Mengz --type atme
# Mark messages as read
gitlink-cli notification +read --type atme --ids 101,102
# Mark all unread notifications as read
gitlink-cli notification +read --type notification --ids -1
# Delete messages
gitlink-cli notification +delete --type notification --ids 101,102
```
### Member Management
```bash
@ -313,6 +366,14 @@ 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"
@ -337,37 +398,6 @@ gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
`issue +view` now returns both `number` and `database_id`, and it enriches the
response with tracker, priority, status, and issue tag metadata when the legacy
issue detail endpoints provide those fields.
`issue +update`, `issue +close`, and `issue +batch-close` preserve existing
tracker, version, assignee, tag, and schedule metadata before sending updates,
which avoids clearing required fields on the server by accident.
### Milestone Management
```bash
# List milestones
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# View a milestone and linked issues
gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 2438 --limit 20
# Build a milestone progress report by ID
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3
# Build a milestone progress report by name
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --name "v1.0"
# Create a milestone
gitlink-cli milestone +create --owner Gitlink --repo forgeplus --name "v1.0" --description "Scope for v1.0" --due-date 2026-07-01
```
`milestone +report` resolves a milestone by `--id` or `--name`, fetches all
linked issues across pages, and summarizes close readiness, assignee/tag gaps,
status distribution, and sample open issues.
### Label Management
```bash
@ -383,19 +413,8 @@ gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something
# Update a label (unspecified fields are preserved)
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
# Delete a label safely
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --dry-run
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --yes
# Batch create labels safely
gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \
--labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --dry-run
gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \
--labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --yes
# Batch delete labels safely
gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --dry-run
gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --yes
# Delete a label
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
```
### Pull Requests
@ -412,7 +431,6 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" -
# View a PR
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# For merged or closed PRs, JSON output also normalizes `created_at`, `merged_at`, `closed_at`, and `closed_on` when GitLink provides or journals can infer them.
# Merge a PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
@ -435,16 +453,6 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# Create a PR review (with dry-run preview)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
# List review comments and unresolved discussion threads
gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened --need-respond true --full
# Create a line-level review comment or reply
gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 -b "Please handle this edge case" --type problem --review-id 7 --line-code abc_1_2 --commit deadbeef --path main.go --dry-run
# Resolve, edit, or delete a review comment
gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 99 --state resolved
gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 99
```
### Branch Management
@ -482,11 +490,6 @@ gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
# List and download release assets
gitlink-cli release +assets --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +download --owner Gitlink --repo forgeplus -i <version_id> --asset gitlink-cli_linux_amd64.tar.gz -o dist/
gitlink-cli release +download --owner Gitlink --repo forgeplus -i <version_id> --archive zip -o dist/source.zip
# Preview release deletion before executing it
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
```
@ -526,6 +529,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### Ignore File Templates
```bash
# List all available .gitignore templates
gitlink-cli ignore +list
# Filter templates by name
gitlink-cli ignore +list --name Go
```
### Search
```bash
@ -536,27 +549,27 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### User Analytics
### User Profile
`profile` surfaces GitLink's native user statistics (ability, role, major, activity,
contribution). When `--user` is omitted it defaults to the authenticated user.
```bash
# Show contribution heatmap for a specific year
gitlink-cli user +headmap --login Mengz --year 2026
# Development ability scores + language breakdown
gitlink-cli profile +ability --user zhangsan
# Show recent activity timeline
gitlink-cli user +activity --login Mengz
# Role positioning / major (discipline) categories
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# Show development capability scores and language distribution
gitlink-cli user +develop --login Mengz --start-time 1735689600 --end-time 1767225599
# Ability within a time window (Unix timestamps)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# Show role distribution across projects
gitlink-cli user +roles --login Mengz
# Recent activity (issues / PRs / commits per day) for the current user
gitlink-cli profile +activity
# Show major domain categories
gitlink-cli user +majors --login Mengz
# Show project trends with cross-page filtering
gitlink-cli user +trends --login Mengz --trend-type PullRequest
gitlink-cli user +trends --login Mengz --keyword release --project gitlink-cli
# Contribution heatmap for a given year
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### Workflow Agent Commands
@ -567,11 +580,9 @@ gitlink-cli user +trends --login Mengz --keyword release --project gitlink-cli
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
- `workflow +stale`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
`workflow +stale` defaults to `markdown` when `--format` is omitted.
Examples:
@ -644,12 +655,6 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
# Stale issue / PR queue report by read-only GitLink fetch
gitlink-cli workflow +stale --owner Gitlink --repo gitlink-cli --stale-days 30 --top 15 --format markdown
# Stale queue report from a local JSON file
gitlink-cli workflow +stale --from shortcuts/workflow/testdata/stale_input.json --format json
```
Output formats:
@ -665,7 +670,32 @@ Safety:
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
- `workflow +stale` scans stale issues and pull requests, classifies them into watch / stale / zombie buckets, and suggests next actions without remote writes.
### Dataset
`dataset` manages and queries GitLink research datasets (title, description,
paper content, license, owning project).
```bash
# List datasets for one or more projects (by numeric project ID)
gitlink-cli dataset +list --ids 5988
# View a repository's dataset and attachments
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# Create / update a repository's dataset (preview first with --dry-run)
gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated"
# Delete a dataset attachment (destructive: preview, then confirm with --yes)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> Note: `dataset +list` (platform dataset query) is verified on production
> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the
> published OpenAPI contract but are not yet deployed on production (they return
> 404 there); they will work once the platform enables them.
### Raw API
@ -686,16 +716,6 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s
# With query parameters
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
# Reuse --owner/--repo placeholders in a single request
gitlink-cli api GET /:owner/:repo/issues --owner Gitlink --repo gitlink-cli --query 'page=1&limit=5'
# Render one-off template variables in path, query, body, and headers
gitlink-cli api POST /{{owner}}/{{repo}}/issues/{{number}}/journals \
--var owner=Gitlink --var repo=gitlink-cli --var number=42 --var actor=codex \
--query 'notify={{actor}}' \
--header 'X-Actor: {{actor}}' \
--body '{"notes":"handled by {{actor}}"}'
```
## Global Parameters
@ -733,19 +753,20 @@ git push gitlink
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details.
See [skills/README.md](./skills/README.md) for details.
| Skill | Description |
|-------|-------------|
| `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) |
| `gitlink-release` | Release management (create, edit, update, view, delete, asset download, etc.) |
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
| `gitlink-notification` | User messages (list, mark read, delete) |
| `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) |
@ -803,7 +824,7 @@ gitlink-cli/
## Documentation
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation
- [Design Document](doc/design.md) — Architecture design and development plan
## FAQ
@ -848,14 +869,6 @@ gitlink-cli repo +list # Ready to use
gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable"
```
To reuse the active token in scripts (e.g. raw `curl` calls against endpoints the CLI does not wrap yet):
```bash
curl -H "Authorization: Bearer $(gitlink-cli auth token)" https://www.gitlink.org.cn/api/v1/...
gitlink-cli auth status --show-token # Inspect the raw token (hidden by default)
echo $MY_TOKEN | gitlink-cli auth login --with-token # Non-interactive login (CI, scripts)
```
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
@ -876,7 +889,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent
### Q: Where can I find the full API reference?
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md).
## License

View File

@ -117,6 +117,7 @@
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 🔔 消息通知设置 | 查看并更新个人消息通知投递偏好 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔔 通知 | 列出、已读、删除用户消息 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料、贡献热力图、活跃度与能力统计 |
@ -347,6 +348,29 @@ gitlink-cli message-settings +update \
gitlink-cli message-settings +preset --name notification-only --all
```
### 消息通知设置
```bash
# 列出可用的消息通知设置分组和键
gitlink-cli message-settings +catalog
# 查看当前用户生效中的消息通知设置
gitlink-cli message-settings +view
# 只看另一个用户的仓库管理类消息设置
gitlink-cli message-settings +view --login Mengz --group ManageProject
# 预览关闭指定设置键的站内通知,不发送请求
gitlink-cli message-settings +update \
--channel notification \
--state off \
--keys Normal::Permission,ManageProject::Issue \
--dry-run
# 将预设应用到所有已知设置
gitlink-cli message-settings +preset --name notification-only --all
```
### Webhook 管理
```bash
@ -389,6 +413,25 @@ gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### 通知管理
```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
```
### 成员管理
```bash
@ -932,6 +975,7 @@ git push gitlink
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-notification` | 用户消息(列表、标记已读、删除) |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |

View File

@ -1,17 +1,25 @@
# Notification shortcut
# Notification Shortcut
新增 `notification` Shortcut 组,封装 GitLink 通知相关 OpenAPI
## Summary
- `notification +list` — 列出通知(`--all` 含已读、`--participating` 仅参与的,支持分页)
- `notification +read` — 标记单条通知为已读(`-i/--id`
- `notification +read-all` — 标记所有通知为已读
- `notification +watch` — 关注 / 取消关注仓库通知(`-o/--owner`、`-r/--repo``--unwatch` 取消)
Adds a `notification` shortcut group for GitLink user messages. The group supports listing messages, marking messages as read, and deleting messages without requiring raw API calls.
实现要点:
## Commands
- `+list` 为 GET `/notifications`,带 `page`/`limit`/`all`/`participating` 查询参数。
- `+read` 为 PUT `/notifications/{id}``+read-all` 为 PUT `/notifications`
- `+watch` 为 POST `/watchers/{owner}/{repo}.json``--unwatch` 时改用 DELETE。
- 全部统一 `owner/repo` 自动解析与 `--format json|table|yaml` 输出。
| Command | Purpose |
|---------|---------|
| `gitlink-cli notification +list` | List messages for the current or specified user |
| `gitlink-cli notification +read` | Mark specific messages, or all unread messages, as read |
| `gitlink-cli notification +delete` | Delete specific messages |
背景:通知管理此前只能在 Web 端手工进行,无法脚本化或被 Agent 调用。`notification` 组补齐命令行入口,便于 CI/Agent 做通知聚合、定期已读、仓库关注等自动化。含单元测试覆盖各命令的 HTTP 方法、路径与查询参数。
## Behavior
- `+list` supports `--type notification|atme|all`, `--status unread|read|all`, and pagination.
- `+read` and `+delete` require `--type notification|atme`.
- `+read --ids -1` marks all unread messages of the selected type as read.
- `+delete` rejects `--ids -1` to avoid accidental bulk deletion.
- When `--user` is omitted, the shortcut resolves the current authenticated user via `/users/me`.
## Tests
The unit tests verify current-user resolution, explicit-user paths, query parameters, read/delete payloads, duplicate ID removal, all-unread handling, and validation failures.

View File

@ -49,6 +49,15 @@
"cmd.issue.short": "Issue operations",
"cmd.issue.update.short": "Update an issue",
"cmd.issue.view.short": "View issue details",
"cmd.notification.delete.short": "Delete messages",
"cmd.notification.list.short": "List user messages",
"cmd.notification.read.short": "Mark messages as read",
"cmd.notification.short": "User message operations",
"cmd.message_settings.catalog.short": "List available message setting groups and keys",
"cmd.message_settings.preset.short": "Apply a preset to selected message settings",
"cmd.message_settings.short": "Message settings operations",
"cmd.message_settings.update.short": "Update selected message settings while preserving other values",
"cmd.message_settings.view.short": "Show effective message settings for a user",
"cmd.org.create.short": "Create an organization",
"cmd.org.info.short": "Show organization details",
"cmd.org.list.short": "List organizations",
@ -178,6 +187,19 @@
"flag.issue.title": "Issue title",
"flag.lang": "Display language",
"flag.limit": "Items per page",
"flag.notification.ids": "Comma-separated message IDs",
"flag.notification.ids_read": "Comma-separated message IDs, or -1 for all unread messages",
"flag.notification.status": "Read status: unread, read, or all",
"flag.notification.type": "Message type: notification or atme",
"flag.notification.type_all": "Message type: notification, atme, or all",
"flag.notification.user": "User login. Defaults to current authenticated user.",
"flag.message_settings.all": "Apply to all known setting keys",
"flag.message_settings.channel": "Channel to change: notification, email, or both",
"flag.message_settings.group": "Filter or select groups by short name, for example: Normal,ManageProject",
"flag.message_settings.keys": "Comma-separated setting keys, for example: Normal::Permission,ManageProject::Issue",
"flag.message_settings.login": "Target user login (defaults to current authenticated user)",
"flag.message_settings.preset_name": "Preset name: all-on, all-off, notification-only, email-only",
"flag.message_settings.state": "Desired state: on/off, true/false, enable/disable",
"flag.org.id": "Organization ID",
"flag.org.id_or_login": "Organization ID or login",
"flag.org.name": "Organization name",

View File

@ -49,6 +49,15 @@
"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.short": "用户消息操作",
"cmd.message_settings.catalog.short": "列出可用的消息通知设置分组和键",
"cmd.message_settings.preset.short": "将预设应用到选中的消息通知设置",
"cmd.message_settings.short": "消息通知设置操作",
"cmd.message_settings.update.short": "在保留其他值的前提下更新选中的消息通知设置",
"cmd.message_settings.view.short": "查看用户当前生效的消息通知设置",
"cmd.org.create.short": "创建组织",
"cmd.org.info.short": "显示组织详情",
"cmd.org.list.short": "列出组织",
@ -178,6 +187,19 @@
"flag.issue.title": "议题标题",
"flag.lang": "显示语言",
"flag.limit": "每页条目数",
"flag.notification.ids": "逗号分隔的消息 ID",
"flag.notification.ids_read": "逗号分隔的消息 ID或用 -1 表示全部未读消息",
"flag.notification.status": "阅读状态unread、read 或 all",
"flag.notification.type": "消息类型notification 或 atme",
"flag.notification.type_all": "消息类型notification、atme 或 all",
"flag.notification.user": "用户登录名,默认使用当前认证用户。",
"flag.message_settings.all": "应用到所有已知的设置键",
"flag.message_settings.channel": "要修改的通道notification、email 或 both",
"flag.message_settings.group": "按短分组名筛选或选中分组例如Normal,ManageProject",
"flag.message_settings.keys": "逗号分隔的设置键例如Normal::Permission,ManageProject::Issue",
"flag.message_settings.login": "目标用户登录名(默认:当前认证用户)",
"flag.message_settings.preset_name": "预设名称all-on、all-off、notification-only、email-only",
"flag.message_settings.state": "目标状态on/off、true/false、enable/disable",
"flag.org.id": "组织 ID",
"flag.org.id_or_login": "组织 ID 或登录名",
"flag.org.name": "组织名称",

View File

@ -3,95 +3,237 @@ package notification
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
var messageTypes = map[string]string{
"notification": "notification",
"atme": "atme",
}
var listStatuses = map[string]string{
"unread": "1",
"read": "2",
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return []*common.Shortcut{
{
Name: "list",
Description: "列出通知",
Description: tr.T("cmd.notification.list.short"),
Flags: []common.Flag{
{Name: "all", Usage: "显示所有通知(含已读)", Bool: true, Default: "false"},
{Name: "participating", Usage: "仅显示参与的通知", Bool: true, Default: "false"},
{Name: "page", Short: "p", Usage: "页码", Default: "1"},
{Name: "limit", Short: "l", Usage: "每页数量", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("all") == "true" {
q.Set("all", "true")
}
if ctx.Arg("participating") == "true" {
q.Set("participating", "true")
}
env, err := ctx.CallAPIWithQuery("GET", "/notifications", q)
if err != nil {
return err
}
return ctx.Output(env)
{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"},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: runList,
},
{
Name: "read",
Description: "标记单条通知为已读",
Description: tr.T("cmd.notification.read.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "通知 ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/notifications/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
{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},
},
Run: runRead,
},
{
Name: "read-all",
Description: "标记所有通知为已读",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("PUT", "/notifications", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watch",
Description: "关注或取消关注仓库的通知",
Name: "delete",
Description: tr.T("cmd.notification.delete.short"),
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "仓库所有者", Required: true},
{Name: "repo", Short: "r", Usage: "仓库名称", Required: true},
{Name: "unwatch", Usage: "取消关注(默认为关注)", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
repo, err := ctx.RequireArg("repo")
if err != nil {
return err
}
path := fmt.Sprintf("/watchers/%s/%s.json", owner, repo)
method := "POST"
if ctx.Arg("unwatch") == "true" {
method = "DELETE"
}
env, err := ctx.CallAPI(method, path, nil)
if err != nil {
return err
}
return ctx.Output(env)
{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"), Required: true},
},
Run: runDelete,
},
}
}
func runList(ctx *common.RuntimeContext) error {
user, err := resolveUserLogin(ctx)
if err != nil {
return err
}
query, err := listQuery(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", messagesPath(user), query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runRead(ctx *common.RuntimeContext) error {
user, payload, err := messagePayload(ctx, true)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", messagesPath(user)+"/read", payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runDelete(ctx *common.RuntimeContext) error {
user, payload, err := messagePayload(ctx, false)
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", messagesPath(user), 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 listQuery(ctx *common.RuntimeContext) (url.Values, error) {
page, err := positiveInt(defaultString(ctx.Arg("page"), "1"), "page")
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
}
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,
"ids": ids,
}, nil
}
func resolveUserLogin(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)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("resolve current user: unexpected response")
}
login, _ := data["login"].(string)
if strings.TrimSpace(login) == "" {
return "", fmt.Errorf("resolve current user: login is missing")
}
return strings.TrimSpace(login), nil
}
func normalizeOptionalType(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || value == "all" {
return "", nil
}
return normalizeRequiredType(value)
}
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{}
for _, part := range parts {
raw := strings.TrimSpace(part)
if raw == "" {
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)
}
if id == -1 && !allowAllUnread {
return nil, fmt.Errorf("invalid --ids value -1: delete requires explicit message IDs")
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("required flag --ids is empty")
}
return ids, 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)
}
return parsed, nil
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

View File

@ -2,6 +2,7 @@ package notification
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@ -10,180 +11,218 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runNotifShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
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 {
t.Helper()
s := findNotifShortcut(t, name)
shortcut := findNotificationShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Format: "json",
Args: args,
}
return s.Run(ctx)
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findNotifShortcut(t *testing.T, name string) *common.Shortcut {
func findNotificationShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeNotifJSON(w http.ResponseWriter, v interface{}) {
func newNotificationTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// --- list ---
func TestNotifListBasic(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/notifications.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("got page %q, want %q", got, "1")
}
if got := r.URL.Query().Get("limit"); got != "20" {
t.Fatalf("got limit %q, want %q", got, "20")
}
writeNotifJSON(w, map[string]interface{}{
"total_count": float64(1),
"notifications": []interface{}{
map[string]interface{}{"id": float64(1), "unread": true},
},
})
}))
defer server.Close()
err := runNotifShortcut(t, server, "list", map[string]string{
"page": "1", "limit": "20", "all": "false", "participating": "false",
})
if err != nil {
t.Fatalf("list failed: %v", err)
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func TestNotifListWithAll(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("all"); got != "true" {
t.Fatalf("expected all=true, got %q", got)
}
writeNotifJSON(w, map[string]interface{}{"total_count": float64(0), "notifications": []interface{}{}})
}))
defer server.Close()
err := runNotifShortcut(t, server, "list", map[string]string{
"page": "1", "limit": "20", "all": "true", "participating": "false",
})
if err != nil {
t.Fatalf("list with all failed: %v", err)
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)
}
}
func TestNotifListWithParticipating(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("participating"); got != "true" {
t.Fatalf("expected participating=true, got %q", got)
}
writeNotifJSON(w, map[string]interface{}{"total_count": float64(0), "notifications": []interface{}{}})
}))
defer server.Close()
err := runNotifShortcut(t, server, "list", map[string]string{
"page": "1", "limit": "20", "all": "false", "participating": "true",
})
if err != nil {
t.Fatalf("list with participating failed: %v", err)
}
}
// --- read ---
func TestNotifRead(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
t.Fatalf("expected PUT, got %s", r.Method)
}
if r.URL.Path != "/notifications/42.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runNotifShortcut(t, server, "read", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("read failed: %v", err)
}
}
// --- read-all ---
func TestNotifReadAll(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
t.Fatalf("expected PUT, got %s", r.Method)
}
if r.URL.Path != "/notifications.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runNotifShortcut(t, server, "read-all", map[string]string{})
if err != nil {
t.Fatalf("read-all failed: %v", err)
}
}
// --- watch ---
func TestNotifWatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/watchers/alice/repo.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runNotifShortcut(t, server, "watch", map[string]string{
"owner": "alice", "repo": "repo", "unwatch": "false",
})
if err != nil {
t.Fatalf("watch failed: %v", err)
}
}
func TestNotifUnwatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/watchers/bob/project.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeNotifJSON(w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runNotifShortcut(t, server, "watch", map[string]string{
"owner": "bob", "repo": "project", "unwatch": "true",
})
if err != nil {
t.Fatalf("unwatch failed: %v", err)
func assertIntSlice(t *testing.T, got interface{}, want []int) {
t.Helper()
values, ok := got.([]interface{})
if !ok {
t.Fatalf("got ids %T, want []interface{}", got)
}
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]))
}
}

View File

@ -9,14 +9,15 @@ 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"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/messagesetting"
"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 +38,57 @@ 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),
"file": file.Shortcuts(),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"message-settings": messagesetting.Shortcuts(tr),
"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"),
"file": "File and directory content operations",
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"message-settings": tr.T("cmd.message_settings.short"),
"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

@ -13,8 +13,8 @@ func TestRegisterAll(t *testing.T) {
expectedGroups := []string{
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki", "file",
"compare", "member", "message-settings", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki", "notification",
}
groupSet := map[string]bool{}

View File

@ -32,7 +32,7 @@ gitlink-cli auth status
gitlink-cli user +me
```
详见: [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md)
详见: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
### 2. 查看可用命令
@ -64,25 +64,19 @@ skills/
├── README.md # 本文件
├── gitlink-shared/ # 共享基础规则
│ ├── SKILL.md # 认证、全局参数、安全规则、分支约定
├── REFERENCE.md # API 详细参考、错误处理
├── TROUBLESHOOTING.md # 常见问题排查
└── examples/
│ └── auth-workflow.md # 认证工作流示例
└── references/
├── api-reference.md # API 详细参考、错误处理
├── raw-api-batch.md # 批量 Raw API 调用参考
│ └── troubleshooting.md # 常见问题排查
├── gitlink-repo/ # 仓库管理
│ ├── SKILL.md # 仓库操作指南
│ ├── REFERENCE.md # 仓库 API 参考
│ └── examples/
│ └── repo-workflow.md # 仓库管理工作流
│ └── references/ # 仓库命令参考文档
├── gitlink-issue/ # Issue 管理
│ ├── SKILL.md # Issue 操作指南
│ ├── REFERENCE.md # Issue API 参考
│ └── examples/
│ └── issue-workflow.md # Issue 全流程工作流
│ └── references/ # Issue 命令参考文档
├── gitlink-pr/ # Pull Request
│ ├── SKILL.md # PR 操作指南
│ ├── REFERENCE.md # PR API 参考
│ └── examples/
│ └── pr-workflow.md # PR 工作流
│ └── references/ # PR 命令参考文档
├── gitlink-member/ # 仓库成员管理
│ └── SKILL.md # 成员与邀请链接操作指南
├── gitlink-branch/ # 分支管理
@ -91,25 +85,26 @@ skills/
│ └── branch-workflow.md # 分支工作流
├── gitlink-release/ # 版本发布
│ ├── SKILL.md # Release 操作指南
├── REFERENCE.md # Release API 参考
│ └── examples/
└── release-workflow.md # Release 工作流
└── references/ # Release 命令参考文档
├── gitlink-release-auto/ # 自动化 Release 管理
└── SKILL.md # 自动发版、版本号推荐、Release Notes 生成
├── gitlink-search/ # 搜索功能
│ ├── SKILL.md # 搜索操作指南
│ └── examples/
│ └── search-workflow.md # 搜索工作流
│ └── references/ # 搜索命令参考文档
├── gitlink-user/ # 用户管理
│ └── SKILL.md # 用户操作指南
│ ├── SKILL.md # 用户操作指南
│ └── references/ # 用户命令参考文档
├── gitlink-org/ # 组织管理
│ ├── SKILL.md # 组织操作指南
│ └── examples/
│ └── org-workflow.md # 组织工作流
│ └── references/ # 组织命令参考文档
├── gitlink-ci/ # CI/CD
│ ├── SKILL.md # CI 操作指南
│ └── examples/
│ └── ci-workflow.md # CI 工作流
│ └── SKILL.md # CI 操作指南
├── gitlink-pipeline/ # 流水线工作流
│ └── SKILL.md # Pipeline 操作指南
├── gitlink-wiki/ # Wiki 页面管理
│ └── SKILL.md # Wiki 操作指南
├── gitlink-notification/ # 用户消息
│ └── SKILL.md # 消息查询、已读和删除指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
@ -135,7 +130,7 @@ skills/
|-------|------|----------|
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
| **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` |
| **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 +create`, `branch +delete`, `branch +protect` |
@ -150,11 +145,11 @@ skills/
| **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-notification** | 用户消息 | `notification +list`, `notification +read`, `notification +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
| **gitlink-maintainer-handoff** | 维护者交接摘要 | `workflow +repo-report`, `pr +list`, `issue +list`, `release +list` |
| **gitlink-branch-hygiene** | 分支治理与清理建议 | `branch +list`, `compare +view`, `pr +list`, `branch +delete` |
---
@ -171,7 +166,7 @@ gitlink-cli repo +info
gitlink-cli repo +info --owner wbtiger --repo gitlink-cli
```
详见: [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md)
详见: [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md)
### 场景 2创建和管理 Issue
@ -192,7 +187,7 @@ gitlink-cli issue +close -i 123
gitlink-cli issue +batch-close --numbers 123,124 --dry-run
```
详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md)
详见: [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md)
### 场景 3管理分支和发布
@ -210,7 +205,7 @@ gitlink-cli release +create -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
gitlink-cli release +view -i <version_id>
```
详见: [gitlink-release/examples/release-workflow.md](gitlink-release/examples/release-workflow.md)
详见: [gitlink-release/SKILL.md](gitlink-release/SKILL.md)
### 场景 4搜索和发现
@ -226,7 +221,7 @@ gitlink-cli org +list
gitlink-cli org +info -i Gitlink
```
详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
详见: [gitlink-search/SKILL.md](gitlink-search/SKILL.md)
---
@ -235,8 +230,8 @@ gitlink-cli org +info -i Gitlink
### 快速查找
- **我想了解认证**: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
- **我想查看 API 细节**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
- **我遇到了错误**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
- **我想查看 API 细节**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
- **我遇到了错误**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
- **我想看工作流示例**: 查看各 Skill 下的 `examples/` 目录
### 按功能分类
@ -244,12 +239,12 @@ gitlink-cli org +info -i Gitlink
**仓库操作**:
- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 仓库命令
- [gitlink-branch/SKILL.md](gitlink-branch/SKILL.md) - 分支命令
- [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md) - 完整工作流
- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 完整工作流
**Issue 和 PR**:
- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 命令
- [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令
- [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) - Issue 工作流
- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 工作流
**发布和搜索**:
- [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令
@ -260,10 +255,6 @@ gitlink-cli org +info -i Gitlink
- [gitlink-org/SKILL.md](gitlink-org/SKILL.md) - 组织命令
- [gitlink-user/SKILL.md](gitlink-user/SKILL.md) - 用户命令
**维护者治理**:
- [gitlink-maintainer-handoff/SKILL.md](gitlink-maintainer-handoff/SKILL.md) - 维护者交接摘要
- [gitlink-branch-hygiene/SKILL.md](gitlink-branch-hygiene/SKILL.md) - 分支治理与清理建议
---
## ❓ 常见问题
@ -295,11 +286,11 @@ gitlink-cli auth login
### Q: 如何查看完整的 API 参考?
A: 查看 [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
A: 查看 [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
### Q: 遇到错误怎么办?
A: 查看 [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
A: 查看 [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
---
@ -335,7 +326,7 @@ AI 代理可以:
- 所有边界情况处理正确
- 完整的文档和示例
详见: [../doc/SKILLS_TEST_REPORT_2026-04-02.md](../doc/SKILLS_TEST_REPORT_2026-04-02.md)
详见: [../doc/design.md](../doc/design.md)
---
@ -343,8 +334,7 @@ AI 代理可以:
- [主项目 README](../README.md) - gitlink-cli 项目说明
- [设计文档](../doc/design.md) - 架构设计和开发计划
- [测试报告](../doc/SKILLS_TEST_REPORT_2026-04-02.md) - 功能测试报告
- [代码同步方案](../doc/CODE_SYNC_STRATEGY_FINAL.md) - GitHub ↔ GitLink 同步设计
- [API 参考文档](../doc/gitlink_api_reference.md) - GitLink API 参考文档
- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - 代码双向同步系统
---
@ -352,8 +342,8 @@ AI 代理可以:
## 📞 获取帮助
- **命令帮助**: `gitlink-cli <command> --help`
- **故障排查**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
- **API 参考**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
- **故障排查**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
- **API 参考**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
- **工作流示例**: 查看各 Skill 下的 `examples/` 目录
---
@ -361,7 +351,7 @@ AI 代理可以:
## 🎓 下一步
1. 阅读 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 了解基础
2. 查看 [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md) 完成认证
2. 查看 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 完成认证
3. 根据需求选择相应的 Skill 文档
4. 参考 `examples/` 目录中的工作流示例
5. 使用 AI 代理自动化你的工作流

View File

@ -0,0 +1,38 @@
---
name: gitlink-notification
version: 1.0.0
description: "User messages: list GitLink messages, mark messages as read, and delete messages."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli notification --help"
---
# gitlink-notification
Use this skill when an agent needs to inspect or update GitLink user messages.
## Shortcuts
| Shortcut | Purpose |
|----------|---------|
| `notification +list` | List user messages |
| `notification +read` | Mark messages as read |
| `notification +delete` | Delete messages |
## Examples
```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
```
## 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`.