diff --git a/README.md b/README.md index cc4cc06..9479fac 100644 --- a/README.md +++ b/README.md @@ -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 jiangtx
jiangtx +
+ luwanzhou +
luwanzhou +
+
+ whale_hihihi +
whale_hihihi +
## 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 gitlink-cli release +edit --owner Gitlink --repo forgeplus -i gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run -# List and download release assets -gitlink-cli release +assets --owner Gitlink --repo forgeplus -i -gitlink-cli release +download --owner Gitlink --repo forgeplus -i --asset gitlink-cli_linux_amd64.tar.gz -o dist/ -gitlink-cli release +download --owner Gitlink --repo forgeplus -i --archive zip -o dist/source.zip - # Preview release deletion before executing it gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --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 --dry-run +gitlink-cli dataset +delete-attachment --owner me --repo proj --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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 36d29fc..158f379 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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、看板、周报等) | diff --git a/doc/changes/notification-shortcut.md b/doc/changes/notification-shortcut.md index 424424c..d705279 100644 --- a/doc/changes/notification-shortcut.md +++ b/doc/changes/notification-shortcut.md @@ -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. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 315787f..05c98ee 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 79c231f..87d9def 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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": "组织名称", diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go index ac58be8..853aa25 100644 --- a/shortcuts/notification/notification.go +++ b/shortcuts/notification/notification.go @@ -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 +} diff --git a/shortcuts/notification/notification_test.go b/shortcuts/notification/notification_test.go index 034b28f..ad2bbd9 100644 --- a/shortcuts/notification/notification_test.go +++ b/shortcuts/notification/notification_test.go @@ -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])) } } diff --git a/shortcuts/register.go b/shortcuts/register.go index b7605d2..e0f06b8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 7bc89a5..a127c5f 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -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{} diff --git a/skills/README.md b/skills/README.md index 17391de..931e498 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 ``` -详见: [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 --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 代理自动化你的工作流 diff --git a/skills/gitlink-notification/SKILL.md b/skills/gitlink-notification/SKILL.md new file mode 100644 index 0000000..fdde65b --- /dev/null +++ b/skills/gitlink-notification/SKILL.md @@ -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`.