feat(notification): add message notification shortcuts

This commit is contained in:
NeeNe 2026-07-02 12:58:32 +08:00
parent 71ca2bb683
commit fb0ebf976f
13 changed files with 1096 additions and 112 deletions

View File

@ -114,6 +114,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔔 Notification | List messages, mark read, delete messages, send @ mentions |
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
@ -274,6 +275,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Notification Management
```bash
# List unread notifications for the authenticated user
gitlink-cli notification +list --status unread --limit 20
# List @ mention messages for a specific user
gitlink-cli notification +list --user zhangsan --type atme --status unread
# Preview and mark selected messages as read
gitlink-cli notification +read --ids 740214,740213 --dry-run
gitlink-cli notification +read --ids 740214,740213 --yes
# Preview and mark all unread system notifications as read
gitlink-cli notification +read --type notification --all-unread --dry-run
# Preview and delete selected messages
gitlink-cli notification +delete --ids 740214,740213 --dry-run
# Send an @ mention message for an Issue, PR, or Journal target
gitlink-cli notification +send-atme --receivers alice,bob \
--atmeable-type Issue --atmeable-id 123 --dry-run
```
### Wiki Management
```bash

View File

@ -113,6 +113,7 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 🔔 通知 | 列出消息、标记已读、删除消息、发送 @ 提及 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
@ -285,6 +286,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### 通知管理
```bash
# 列出当前认证用户的未读通知
gitlink-cli notification +list --status unread --limit 20
# 列出指定用户的 @ 我消息
gitlink-cli notification +list --user zhangsan --type atme --status unread
# 预览并将指定消息标记为已读
gitlink-cli notification +read --ids 740214,740213 --dry-run
gitlink-cli notification +read --ids 740214,740213 --yes
# 预览将全部未读系统通知标记为已读
gitlink-cli notification +read --type notification --all-unread --dry-run
# 预览删除指定消息
gitlink-cli notification +delete --ids 740214,740213 --dry-run
# 为 Issue、PR 或 Journal 目标发送 @ 提及消息
gitlink-cli notification +send-atme --receivers alice,bob \
--atmeable-type Issue --atmeable-id 123 --dry-run
```
### Wiki 管理
```bash

View File

@ -0,0 +1,82 @@
# Notification shortcuts
## Background
GitLink exposes user messages and notifications through the messages API. The
existing `gitlink-notification-digest` Skill had to call Raw API paths directly
to list notifications and mark messages as read. That made agent workflows more
fragile and forced users to remember GitLink's "messages" terminology.
This change adds a first-class `notification` shortcut group.
## New shortcuts
- `notification +list` lists messages for the authenticated user or a specified
user, with type/status/page/limit filters.
- `notification +read` marks selected messages as read, or marks all unread
messages of a selected type as read.
- `notification +delete` deletes selected messages.
- `notification +send-atme` sends @ mention messages for `Journal`, `Issue`, or
`PullRequest` targets.
## Safety model
Read-only listing runs directly:
```bash
gitlink-cli notification +list --status unread --limit 20
```
Remote write operations require explicit confirmation and support dry-run
previews:
```bash
gitlink-cli notification +read --ids 740214,740213 --dry-run
gitlink-cli notification +read --ids 740214,740213 --yes
```
```bash
gitlink-cli notification +delete --ids 740214,740213 --dry-run
gitlink-cli notification +delete --ids 740214,740213 --yes
```
```bash
gitlink-cli notification +send-atme --receivers alice,bob \
--atmeable-type Issue --atmeable-id 123 --dry-run
```
`notification +read --all-unread` maps to GitLink's `ids: [-1]` convention.
The delete command does not expose `--all-unread` to avoid accidental broad
deletion.
## Documentation updates
- README and README.zh-CN include notification usage examples.
- `skills/gitlink-notification/` documents the new shortcut group.
- `skills/gitlink-notification-digest` now prefers `notification +list` and
`notification +read` instead of Raw API calls.
- The Skills overview lists the new notification Skill.
## Tests
Unit tests cover:
- list endpoint path, filters, pagination, and current-user fallback;
- dry-run behavior for all write operations;
- confirmation guard without `--yes`;
- read payload construction for selected IDs and all unread messages;
- delete payload construction;
- send-atme payload construction and validation;
- invalid argument handling before remote calls.
Suggested verification:
```bash
go test ./shortcuts/notification
```
Full project verification:
```bash
go test ./...
```

View File

@ -47,6 +47,11 @@
"cmd.issue.short": "Issue operations",
"cmd.issue.update.short": "Update an issue",
"cmd.issue.view.short": "View issue details",
"cmd.notification.delete.short": "Delete user messages",
"cmd.notification.list.short": "List user messages and notifications",
"cmd.notification.read.short": "Mark user messages as read",
"cmd.notification.send_atme.short": "Send @ mention messages",
"cmd.notification.short": "Notification and message operations",
"cmd.org.create.short": "Create an organization",
"cmd.org.info.short": "Show organization details",
"cmd.org.list.short": "List organizations",
@ -112,6 +117,7 @@
"error.config.save_failed": "failed to save config: {message}",
"error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm",
"error.missing_required_flag": "required flag --{name} is missing",
"error.notification.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
@ -173,6 +179,16 @@
"flag.issue.title": "Issue title",
"flag.lang": "Display language",
"flag.limit": "Items per page",
"flag.notification.all_unread": "Mark all unread messages of the selected type as read",
"flag.notification.atmeable_id": "@ message target object ID",
"flag.notification.atmeable_type": "@ message target type: Journal, Issue, or PullRequest",
"flag.notification.dry_run": "Preview the message request without changing remote state",
"flag.notification.ids": "Comma-separated message IDs",
"flag.notification.receivers": "Comma-separated receiver logins",
"flag.notification.status": "Filter status: unread, read, 1, or 2",
"flag.notification.type": "Message type: notification or atme",
"flag.notification.user": "Target user login (defaults to the authenticated user)",
"flag.notification.yes": "Confirm the remote message operation",
"flag.org.id": "Organization ID",
"flag.org.id_or_login": "Organization ID or login",
"flag.org.name": "Organization name",

View File

@ -47,6 +47,11 @@
"cmd.issue.short": "议题操作",
"cmd.issue.update.short": "更新议题",
"cmd.issue.view.short": "查看议题详情",
"cmd.notification.delete.short": "删除用户消息",
"cmd.notification.list.short": "列出用户消息和通知",
"cmd.notification.read.short": "将用户消息标记为已读",
"cmd.notification.send_atme.short": "发送 @ 提及消息",
"cmd.notification.short": "通知与消息操作",
"cmd.org.create.short": "创建组织",
"cmd.org.info.short": "显示组织详情",
"cmd.org.list.short": "列出组织",
@ -112,6 +117,7 @@
"error.config.save_failed": "保存配置失败:{message}",
"error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.notification.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
@ -173,6 +179,16 @@
"flag.issue.title": "议题标题",
"flag.lang": "显示语言",
"flag.limit": "每页条目数",
"flag.notification.all_unread": "将所选类型的全部未读消息标记为已读",
"flag.notification.atmeable_id": "@ 消息目标对象 ID",
"flag.notification.atmeable_type": "@ 消息目标类型Journal、Issue 或 PullRequest",
"flag.notification.dry_run": "预览消息请求,不修改远端状态",
"flag.notification.ids": "消息 ID多个用英文逗号分隔",
"flag.notification.receivers": "接收者登录名,多个用英文逗号分隔",
"flag.notification.status": "筛选状态unread、read、1 或 2",
"flag.notification.type": "消息类型notification 或 atme",
"flag.notification.user": "目标用户登录名(默认为当前认证用户)",
"flag.notification.yes": "确认执行远端消息操作",
"flag.org.id": "组织 ID",
"flag.org.id_or_login": "组织 ID 或登录名",
"flag.org.name": "组织名称",

View File

@ -0,0 +1,334 @@
// Package notification implements GitLink user message shortcuts.
package notification
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const allUnreadMessageID = -1
// Shortcuts returns user notification/message shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
userFlag := common.Flag{Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}
typeFlag := common.Flag{Name: "type", Short: "t", Usage: tr.T("flag.notification.type")}
return []*common.Shortcut{
{
Name: "list",
Description: tr.T("cmd.notification.list.short"),
Flags: []common.Flag{
userFlag,
typeFlag,
{Name: "status", Short: "s", Usage: tr.T("flag.notification.status")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: runList,
},
{
Name: "read",
Description: tr.T("cmd.notification.read.short"),
Flags: []common.Flag{
userFlag,
typeFlag,
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids")},
{Name: "all-unread", Usage: tr.T("flag.notification.all_unread"), Bool: true, Default: "false"},
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
},
Run: runRead,
},
{
Name: "delete",
Description: tr.T("cmd.notification.delete.short"),
Flags: []common.Flag{
userFlag,
typeFlag,
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids"), Required: true},
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
},
Run: runDelete,
},
{
Name: "send-atme",
Description: tr.T("cmd.notification.send_atme.short"),
Flags: []common.Flag{
userFlag,
{Name: "receivers", Short: "r", Usage: tr.T("flag.notification.receivers"), Required: true},
{Name: "atmeable-type", Usage: tr.T("flag.notification.atmeable_type"), Required: true},
{Name: "atmeable-id", Usage: tr.T("flag.notification.atmeable_id"), Required: true},
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
},
Run: runSendAtme,
},
}
}
func runList(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
q := url.Values{}
setQueryIfPresent(q, "type", ctx.Arg("type"))
setQueryIfPresent(q, "status", normalizeStatus(ctx.Arg("status")))
q.Set("page", firstNonEmpty(ctx.Arg("page"), "1"))
q.Set("limit", firstNonEmpty(ctx.Arg("limit"), "20"))
env, err := ctx.CallAPIWithQuery("GET", messagesPath(user), q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runRead(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
payload, err := readPayload(ctx)
if err != nil {
return err
}
path := messagesReadPath(user)
if ctx.Arg("dry-run") == "true" {
return writeDryRun(ctx, "read_notifications", "POST", path, user, payload)
}
if ctx.Arg("yes") != "true" {
return fmt.Errorf("marking messages as read changes remote state; run --dry-run first, then pass --yes to execute")
}
env, err := ctx.CallAPI("POST", path, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runDelete(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
ids, err := parseIDs(ctx.Arg("ids"))
if err != nil {
return err
}
payload := messageActionPayload(ctx.Arg("type"), ids)
path := messagesPath(user)
if ctx.Arg("dry-run") == "true" {
return writeDryRun(ctx, "delete_notifications", "DELETE", path, user, payload)
}
if ctx.Arg("yes") != "true" {
return fmt.Errorf("deleting messages is destructive; run --dry-run first, then pass --yes to execute")
}
env, err := ctx.CallAPI("DELETE", path, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runSendAtme(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
payload, err := sendAtmePayload(ctx)
if err != nil {
return err
}
path := messagesPath(user)
if ctx.Arg("dry-run") == "true" {
return writeDryRun(ctx, "send_atme", "POST", path, user, payload)
}
if ctx.Arg("yes") != "true" {
return fmt.Errorf("sending @ messages changes remote state; run --dry-run first, then pass --yes to execute")
}
env, err := ctx.CallAPI("POST", path, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func readPayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
if ctx.Arg("all-unread") == "true" {
if strings.TrimSpace(ctx.Arg("ids")) != "" {
return nil, fmt.Errorf("--ids and --all-unread cannot be used together")
}
return messageActionPayload(ctx.Arg("type"), []int{allUnreadMessageID}), nil
}
ids, err := parseIDs(ctx.Arg("ids"))
if err != nil {
return nil, err
}
return messageActionPayload(ctx.Arg("type"), ids), nil
}
func messageActionPayload(messageType string, ids []int) map[string]interface{} {
return map[string]interface{}{
"type": firstNonEmpty(strings.TrimSpace(messageType), "notification"),
"ids": ids,
}
}
func sendAtmePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
receivers, err := parseStringList(ctx.Arg("receivers"), "--receivers")
if err != nil {
return nil, err
}
atmeableType, err := ctx.RequireArg("atmeable-type")
if err != nil {
return nil, err
}
atmeableID, err := parsePositiveInt(ctx.Arg("atmeable-id"), "--atmeable-id")
if err != nil {
return nil, err
}
return map[string]interface{}{
"type": "atme",
"receivers_login": receivers,
"atmeable_type": atmeableType,
"atmeable_id": atmeableID,
}, nil
}
func writeDryRun(ctx *common.RuntimeContext, action, method, path, user string, payload map[string]interface{}) error {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": action,
"method": method,
"path": path,
"user": user,
"payload": payload,
})
}
func resolveUser(ctx *common.RuntimeContext) (string, error) {
if user := strings.TrimSpace(ctx.Arg("user")); user != "" {
return user, nil
}
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return "", err
}
if login := extractLogin(env); login != "" {
return login, nil
}
return "", fmt.Errorf("%s", ctx.Tr.T("error.notification.user_required"))
}
func extractLogin(env *output.Envelope) string {
data, ok := env.Data.(map[string]interface{})
if !ok {
return ""
}
if login, ok := data["login"].(string); ok {
return login
}
return ""
}
func parseIDs(raw string) ([]int, error) {
return parseIntList(raw, "--ids", true)
}
func parseIntList(raw, flag string, positiveOnly bool) ([]int, error) {
parts := strings.Split(raw, ",")
values := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid %s value %q: use comma-separated integers", flag, part)
}
if positiveOnly && value <= 0 {
return nil, fmt.Errorf("invalid %s value %q: use a positive integer", flag, part)
}
values = append(values, value)
}
if len(values) == 0 {
return nil, fmt.Errorf("%s must include at least one id", flag)
}
return values, nil
}
func parsePositiveInt(raw, flag string) (int, error) {
values, err := parseIntList(raw, flag, true)
if err != nil {
return 0, err
}
if len(values) != 1 {
return 0, fmt.Errorf("%s must include exactly one id", flag)
}
return values[0], nil
}
func parseStringList(raw, flag string) ([]string, error) {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
values = append(values, part)
}
}
if len(values) == 0 {
return nil, fmt.Errorf("%s must include at least one value", flag)
}
return values, nil
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "unread":
return "1"
case "read":
return "2"
default:
return strings.TrimSpace(status)
}
}
func messagesPath(user string) string {
return fmt.Sprintf("/api/users/%s/messages", url.PathEscape(user))
}
func messagesReadPath(user string) string {
return fmt.Sprintf("%s/read", messagesPath(user))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func setQueryIfPresent(q url.Values, key, value string) {
if strings.TrimSpace(value) != "" {
q.Set(key, strings.TrimSpace(value))
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,391 @@
package notification
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Format: "json",
Args: args,
Tr: i18n.Default(),
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write JSON: %v", err)
}
}
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("decode request body: %v", err)
}
return payload
}
func assertPath(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method {
t.Fatalf("method = %s, want %s", r.Method, method)
}
if r.URL.Path != path {
t.Fatalf("path = %s, want %s", r.URL.Path, path)
}
}
// --- list ---
func TestNotificationListExplicitUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "GET", "/api/users/alice/messages.json")
if got := r.URL.Query().Get("type"); got != "atme" {
t.Fatalf("type = %q, want atme", got)
}
if got := r.URL.Query().Get("status"); got != "1" {
t.Fatalf("status = %q, want 1", got)
}
if got := r.URL.Query().Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := r.URL.Query().Get("limit"); got != "50" {
t.Fatalf("limit = %q, want 50", got)
}
writeJSON(t, w, map[string]interface{}{"total_count": 1, "messages": []interface{}{}})
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"user": "alice",
"type": "atme",
"status": "unread",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestNotificationListDefaultsToCurrentUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(t, w, map[string]interface{}{"login": "current"})
case "/api/users/current/messages.json":
if got := r.URL.Query().Get("status"); got != "2" {
t.Fatalf("status = %q, want 2", got)
}
writeJSON(t, w, map[string]interface{}{"messages": []interface{}{}})
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"status": "read"}); err != nil {
t.Fatalf("list default user failed: %v", err)
}
}
func TestNotificationDefaultUserMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "GET", "/users/me.json")
writeJSON(t, w, map[string]interface{}{"name": "No Login"})
}))
defer server.Close()
if err := runShortcut(t, server, "list", nil); err == nil {
t.Fatal("expected error when current user login is unavailable")
}
}
// --- read ---
func TestNotificationReadDryRunDoesNotCallRemoteWrite(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{
"user": "alice",
"ids": "1,2,3",
"dry-run": "true",
})
if err != nil {
t.Fatalf("read dry-run failed: %v", err)
}
}
func TestNotificationReadRequiresYesForRemoteWrite(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("read without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "ids": "1"})
if err == nil {
t.Fatal("expected error when read is missing --yes")
}
}
func TestNotificationReadPostsIDs(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{
"user": "alice",
"type": "atme",
"ids": "4,5",
"yes": "true",
})
if err != nil {
t.Fatalf("read failed: %v", err)
}
if payload["type"] != "atme" {
t.Fatalf("type = %#v, want atme", payload["type"])
}
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{4, 5}) {
t.Fatalf("ids = %#v, want [4 5]", payload["ids"])
}
}
func TestNotificationReadAllUnread(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{
"user": "alice",
"all-unread": "true",
"yes": "true",
})
if err != nil {
t.Fatalf("read all-unread failed: %v", err)
}
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{-1}) {
t.Fatalf("ids = %#v, want [-1]", payload["ids"])
}
}
func TestNotificationReadRejectsIDsWithAllUnread(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{
"user": "alice",
"ids": "1",
"all-unread": "true",
"dry-run": "true",
})
if err == nil {
t.Fatal("expected error when --ids and --all-unread are combined")
}
}
func TestNotificationReadRequiresIDsOrAllUnread(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "dry-run": "true"})
if err == nil {
t.Fatal("expected error when read has no ids")
}
}
// --- delete ---
func TestNotificationDeleteDryRunDoesNotCallRemoteWrite(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{
"user": "alice",
"ids": "9",
"dry-run": "true",
})
if err != nil {
t.Fatalf("delete dry-run failed: %v", err)
}
}
func TestNotificationDeleteRequiresYes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("delete without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"user": "alice", "ids": "9"})
if err == nil {
t.Fatal("expected error when delete is missing --yes")
}
}
func TestNotificationDeleteCallsEndpoint(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "DELETE", "/api/users/alice/messages.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{
"user": "alice",
"type": "notification",
"ids": "10,11",
"yes": "true",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{10, 11}) {
t.Fatalf("ids = %#v, want [10 11]", payload["ids"])
}
}
// --- send-atme ---
func TestNotificationSendAtmeDryRunDoesNotCallRemoteWrite(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "send-atme", map[string]string{
"user": "alice",
"receivers": "bob,carol",
"atmeable-type": "Issue",
"atmeable-id": "42",
"dry-run": "true",
})
if err != nil {
t.Fatalf("send-atme dry-run failed: %v", err)
}
}
func TestNotificationSendAtmeRequiresYes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("send-atme without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "send-atme", map[string]string{
"user": "alice",
"receivers": "bob",
"atmeable-type": "Issue",
"atmeable-id": "42",
})
if err == nil {
t.Fatal("expected error when send-atme is missing --yes")
}
}
func TestNotificationSendAtmeCallsEndpoint(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertPath(t, r, "POST", "/api/users/alice/messages.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runShortcut(t, server, "send-atme", map[string]string{
"user": "alice",
"receivers": "bob, carol",
"atmeable-type": "PullRequest",
"atmeable-id": "77",
"yes": "true",
})
if err != nil {
t.Fatalf("send-atme failed: %v", err)
}
if payload["type"] != "atme" {
t.Fatalf("type = %#v, want atme", payload["type"])
}
if got, ok := payload["receivers_login"].([]interface{}); !ok || len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
t.Fatalf("receivers_login = %#v, want [bob carol]", payload["receivers_login"])
}
if payload["atmeable_type"] != "PullRequest" {
t.Fatalf("atmeable_type = %#v, want PullRequest", payload["atmeable_type"])
}
if payload["atmeable_id"] != float64(77) {
t.Fatalf("atmeable_id = %#v, want 77", payload["atmeable_id"])
}
}
func TestNotificationSendAtmeRejectsInvalidID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "send-atme", map[string]string{
"user": "alice",
"receivers": "bob",
"atmeable-type": "Issue",
"atmeable-id": "0",
"dry-run": "true",
})
if err == nil {
t.Fatal("expected invalid atmeable-id error")
}
}
func floatSliceToInts(raw interface{}) []int {
values, ok := raw.([]interface{})
if !ok {
return nil
}
ints := make([]int, 0, len(values))
for _, value := range values {
if number, ok := value.(float64); ok {
ints = append(ints, int(number))
}
}
return ints
}

View File

@ -16,6 +16,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/notification"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
@ -36,53 +37,55 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"notification": notification.Shortcuts(tr),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"notification": tr.T("cmd.notification.short"),
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {

View File

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

View File

@ -94,6 +94,8 @@ skills/
├── gitlink-user/ # 用户管理
│ ├── SKILL.md # 用户操作指南
│ └── references/ # 用户命令参考文档
├── gitlink-notification/ # 通知与消息管理
│ └── SKILL.md # 消息查看、标记已读、删除和 @ 提及
├── gitlink-org/ # 组织管理
│ ├── SKILL.md # 组织操作指南
│ └── references/ # 组织命令参考文档
@ -140,6 +142,7 @@ skills/
|-------|------|----------|
| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` |
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
| **gitlink-notification** | 通知与消息管理 | `notification +list`, `notification +read`, `notification +delete` |
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |

View File

@ -4,21 +4,17 @@
**日期**2026-06-03
**用户**lindiwen23
**CLI 版本**gitlink-cli 0.1.18
**CLI 版本**支持 `notification` shortcut 的 gitlink-cli
### 执行流程
```bash
# Step 1: 获取用户名
gitlink-cli auth status
# → Logged in as lindiwen23
# Step 2: 获取未读通知status=1
gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json
gitlink-cli notification +list --status unread --limit 20 --format json
# → 7 条未读unread_notification=7, unread_atme=0
# Step 3: 获取已读通知(用于趋势分析和回顾)
gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json
gitlink-cli notification +list --status read --limit 20 --format json
# → 21 条已读
# Step 4: 分类统计、生成摘要报告
@ -31,9 +27,8 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
| 未读通知 | 7 条 |
| @我未读 | 0 条 |
| 总通知 | 28 条7 未读 + 21 已读) |
| 不存在命令 | `gitlink-cli notification`(整个子命令不存在) |
| 实际 API | `GET /api/users/{owner}/messages.json` |
| CLI Bug | `api` 路径以 `/` 开头会被解析为本地文件路径 |
| 推荐命令 | `gitlink-cli notification +list` |
| 标记已读 | `gitlink-cli notification +read --ids ... --dry-run/--yes` |
### 原始 API 返回(未读 7 条)
@ -176,12 +171,11 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
### 经验总结
1. **`gitlink-cli notification` 命令不存在**GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API
2. **API 端点是 `messages` 不是 `notifications`**GitLink 用「消息」术语
3. **CLI 路径 Bug**`gitlink-cli api` 的 PATH 参数以 `/` 开头会被解析为本地文件路径,必须去掉前导 `/`
4. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息
5. **没有批量已读 API**:标记已读需逐条调用 `POST users/{owner}/messages/{id}/read`
6. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme匹配时注意
1. **优先使用 `notification` shortcut**:列表、标记已读、删除和发送 @ 消息均已有封装
2. **API 端点是 `messages` 不是 `notifications`**GitLink 用「消息」术语shortcut 已屏蔽路径细节
3. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息
4. **标记已读是写操作**:必须先 `--dry-run`,用户确认后再 `--yes`
5. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme匹配时注意
---
@ -194,22 +188,17 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
```
工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件
工具调用 2: Bash → gitlink-cli auth status ← 获取用户名
工具调用 3: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
--query "status=1&limit=20" --format json ← 获取未读
工具调用 4: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
--query "status=2&limit=20" --format json ← 获取已读(趋势分析)
工具调用 5: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
--query "limit=20" --format json ← 获取全部(总计统计)
工具调用 2: Bash → gitlink-cli notification +list --status unread --limit 20 --format json
工具调用 3: Bash → gitlink-cli notification +list --status read --limit 20 --format json
工具调用 4: Bash → gitlink-cli notification +list --limit 20 --format json
```
### Agent 决策过程
Agent **正确遵循了 skill v2.0.0 的工作流**
Agent **正确遵循了 skill v3.0.0 的工作流**
1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数
2. 用 `auth status` 获取当前用户 `lindiwen23`
3. 使用 Raw API路径无前导 `/`)获取未读、已读、全部三类数据
2. 使用 `notification +list` 获取未读、已读、全部三类数据
4. 按 `source` 字段分类:`ProjectPullRequest` → P2`ProjectPraised`/`ProjectMemberJoined` → P3
5. 按输出模板生成结构化报告,含所有七个章节
6. 主动询问是否需要标记 P3 通知为已读
@ -268,9 +257,9 @@ Agent **正确遵循了 skill v2.0.0 的工作流**
### 验证结论
✅ skill v2.0.0 验证通过:
- Agent 正确使用了 `gitlink-cli api` 而非不存在的 `gitlink-cli notification`
- Agent 路径没有以 `/` 开头,避开了 CLI 路径解析 Bug
✅ skill v3.0.0 验证通过:
- Agent 正确使用了 `gitlink-cli notification +list` 获取消息列表
- Agent 正确使用了 `gitlink-cli notification +read --dry-run` 预览标记已读操作
- Agent 按 `source` 枚举值正确分类,识别出 `PullReuqestAtme` 拼写异常
- Agent 正确区分了 P0/P1/P2/P3 优先级
- Agent 使用 `unread_notification`/`unread_atme` 顶层字段快速统计
@ -283,22 +272,22 @@ Agent **正确遵循了 skill v2.0.0 的工作流**
| 场景 | 检测方式 | 处理 |
|------|----------|------|
| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` |
| API 返回 HTML 而非 JSON | 响应以 `<!doctype html>` 开头 | 去掉路径前导 `/` 重试 |
| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` |
| 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 |
| `notification +list` 失败 | 查看错误信息 | 先确认已登录,再运行 `gitlink-cli notification +list --format json` |
| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--page 2` |
| 用户名不确定 | shortcut 自动解析当前用户失败 | 先执行 `gitlink-cli auth status` |
| 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" |
---
## 版本兼容性说明
本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更:
本 skill v3.0.0 基于新增的 `notification` shortcut 编写。关键变更:
| 版本 | `notification` 子命令 | 实际 API | 标记已读 |
|------|----------------------|----------|----------|
| v1.0.0 | `notification +list`(虚构) | 不存在 | `notification +read-all`(虚构) |
| v2.0.0 | 无此子命令 | `GET /api/users/{owner}/messages.json` | `POST /api/users/{owner}/messages/{id}/read` |
| v3.0.0 | `notification +list` | 由 shortcut 封装 messages API | `notification +read --ids ... --dry-run/--yes` |
当 CLI 版本更新后,重新验证可用命令:
```bash

View File

@ -32,17 +32,9 @@ metadata:
## ⚠️ 关键注意事项
### CLI 路径处理 Bug
### CLI Shortcut 优先
**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。
```bash
# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/...
gitlink-cli api GET /users/me
# ✅ 正确 — 去掉前导 /
gitlink-cli api GET "users/{owner}/messages.json"
```
通知摘要优先使用 `notification` shortcut不再直接拼接 Raw API 路径。只有 shortcut 未覆盖的新接口才回退到 `gitlink-cli api`
### 术语对照
@ -54,33 +46,33 @@ GitLink 平台用「**消息**」messages而不是「通知」notificat
### Step 1获取通知列表
使用 Raw API 调用 `/api/users/{owner}/messages.json`
使用 `notification +list` 获取消息列表
```bash
# 获取未读通知status=1 表示未读2 表示已读)
gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json
gitlink-cli notification +list --status unread --limit 20 --format json
# 获取全部通知(含已读)
gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json
gitlink-cli notification +list --limit 20 --format json
# 分页获取
gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&page=2&limit=20" --format json
gitlink-cli notification +list --status unread --page 2 --limit 20 --format json
# 按类型过滤
# type=notification 系统消息仓库动态、PR、Issue 等)
# type=atme @我消息
gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json
gitlink-cli notification +list --type atme --status unread --limit 20 --format json
```
**参数说明:**
| 参数 | 位置 | 说明 |
|------|------|------|
| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) |
| `status` | Query | 1=未读2=已读,不传=全部 |
| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 |
| `page` | Query | 页码(默认 1 |
| `limit` | Query | 每页条数(默认 20 |
| `--user` | Flag | 目标用户登录名,不传时自动使用当前认证用户 |
| `--status` | Flag | `unread`/`1`=未读,`read`/`2`=已读,不传=全部 |
| `--type` | Flag | `notification`=系统消息,`atme`=@我消息,不传=全部 |
| `--page` | Flag | 页码(默认 1 |
| `--limit` | Flag | 每页条数(默认 20 |
**响应结构:**
@ -193,17 +185,18 @@ gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&li
### Step 4标记已读可选需确认
```bash
# 标记单条已读
gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json
# 预览标记指定消息为已读
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --dry-run --format json
# 批量标记已读 — 逐条调用GitLink 暂无批量已读 API
for id in <id1> <id2> <id3>; do
gitlink-cli api POST "users/{owner}/messages/$id/read" --format json
done
# 确认执行
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --yes --format json
# 预览将全部未读系统通知标记为已读
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
```
> ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。
> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。
> ⚠️ **必须先 dry-run再由用户确认后加 `--yes` 执行。**
---
@ -283,7 +276,7 @@ done
- 需要回复/处理:{{need_action_count}} 条 P0/P1 通知
如需标记 P3 通知为已读,我可以逐条执行:
`gitlink-cli api POST "users/{owner}/messages/{id}/read"`
`gitlink-cli notification +read --ids <ids> --dry-run`
```
---
@ -294,9 +287,8 @@ done
|------|----------|
| 无未读通知 | 输出"🎉 所有通知已处理完毕" |
| 通知数量 > 50 | 分页获取page 1/2/3优先分析最近 50 条 |
| API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 |
| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 |
| 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 |
| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--page 2` 获取 |
| 用户名不确定 | `notification +list` 会自动读取当前认证用户;失败时先执行 `gitlink-cli auth status` |
---
@ -305,7 +297,6 @@ done
- ✅ **所有命令使用 `--format json`**,确保可解析
- ✅ **标记已读为写操作**,执行前必须确认用户意图
- ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读
- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**CLI Bug
- ⚠️ **GitLink 用「消息messages」而非「通知notifications」**
- ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配
- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"`
- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2`

View File

@ -0,0 +1,109 @@
---
name: gitlink-notification
version: 1.0.0
description: "通知与消息管理:查看 GitLink 通知、标记已读、删除消息、发送 @ 提及消息。当用户需要查看或管理站内消息/通知时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
---
# gitlink-notification通知与消息管理
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和安全规则。**
**CRITICAL — 标记已读、删除消息和发送 @ 消息都是写操作,执行前必须先 dry-run 并确认用户意图。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。
## 功能概述
GitLink 平台的通知在 API 中称为 messages。本 Skill 使用 `notification` shortcut 管理用户消息:
| 命令 | 用途 | 是否写操作 |
|------|------|------------|
| `notification +list` | 列出用户消息和通知 | 否 |
| `notification +read` | 将消息标记为已读 | 是 |
| `notification +delete` | 删除消息 | 是 |
| `notification +send-atme` | 发送 @ 提及消息 | 是 |
## 常用命令
```bash
# 查看当前认证用户的未读通知
gitlink-cli notification +list --status unread --limit 20 --format json
# 查看 @ 我消息
gitlink-cli notification +list --type atme --status unread --format json
# 查看指定用户消息
gitlink-cli notification +list --user zhangsan --type notification --status read --page 1 --limit 20 --format json
# 预览标记指定消息为已读
gitlink-cli notification +read --ids 740214,740213 --dry-run --format json
# 确认标记指定消息为已读
gitlink-cli notification +read --ids 740214,740213 --yes --format json
# 预览将全部未读系统通知标记为已读
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
# 预览删除指定消息
gitlink-cli notification +delete --ids 740214,740213 --dry-run --format json
# 发送 @ 提及消息,先 dry-run
gitlink-cli notification +send-atme --receivers alice,bob \
--atmeable-type Issue --atmeable-id 123 --dry-run --format json
```
## 参数
### `notification +list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--user, -u` | 否 | 目标用户登录名,默认使用当前认证用户 |
| `--type, -t` | 否 | 消息类型:`notification` 或 `atme` |
| `--status, -s` | 否 | 状态:`unread`/`1` 或 `read`/`2` |
| `--page, -p` | 否 | 页码,默认 `1` |
| `--limit, -l` | 否 | 每页数量,默认 `20` |
### `notification +read`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--ids, -i` | 条件必填 | 消息 ID多个用英文逗号分隔 |
| `--all-unread` | 条件必填 | 将所选类型全部未读消息标记为已读 |
| `--type, -t` | 否 | 消息类型,默认 `notification` |
| `--dry-run` | 否 | 预览请求,不修改远端 |
| `--yes` | 否 | 确认执行远端写入 |
### `notification +delete`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--ids, -i` | 是 | 要删除的消息 ID多个用英文逗号分隔 |
| `--type, -t` | 否 | 消息类型,默认 `notification` |
| `--dry-run` | 否 | 预览删除请求 |
| `--yes` | 否 | 确认执行删除 |
### `notification +send-atme`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--receivers, -r` | 是 | 接收者登录名,多个用英文逗号分隔 |
| `--atmeable-type` | 是 | @ 消息目标类型:`Journal`、`Issue` 或 `PullRequest` |
| `--atmeable-id` | 是 | @ 消息目标对象 ID |
| `--dry-run` | 否 | 预览发送请求 |
| `--yes` | 否 | 确认发送 |
## 安全规则
- `notification +read`、`notification +delete` 和 `notification +send-atme` 默认不会修改远端状态。
- 真实执行前必须先使用 `--dry-run` 查看 `payload`
- 用户明确确认后,才可以加 `--yes` 执行。
- `notification +read --all-unread` 会向 API 发送 `ids: [-1]`,表示所选类型的全部未读消息。
- `notification +delete` 不支持 `--all-unread`,避免误删大量消息。
## 参考
- [gitlink-shared](../gitlink-shared/SKILL.md)
- [gitlink-notification-digest](../gitlink-notification-digest/SKILL.md)