From b0a1f0e56613922e88bddf1f28db1a63313416d4 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 27 May 2026 17:22:57 +0800 Subject: [PATCH 1/3] feat: add notification shortcuts --- README.md | 21 ++ README.zh-CN.md | 21 ++ doc/changes/notification-shortcut.md | 25 ++ internal/i18n/locales/en-US.json | 10 + internal/i18n/locales/zh-CN.json | 10 + shortcuts/notification/notification.go | 239 ++++++++++++++++++++ shortcuts/notification/notification_test.go | 228 +++++++++++++++++++ shortcuts/register.go | 91 ++++---- shortcuts/register_test.go | 2 +- skills/README.md | 3 + skills/gitlink-notification/SKILL.md | 38 ++++ 11 files changed, 643 insertions(+), 45 deletions(-) create mode 100644 doc/changes/notification-shortcut.md create mode 100644 shortcuts/notification/notification.go create mode 100644 shortcuts/notification/notification_test.go create mode 100644 skills/gitlink-notification/SKILL.md diff --git a/README.md b/README.md index e5e4318..694968f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 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 | @@ -296,6 +297,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 ``` +### 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 @@ -745,6 +765,7 @@ See [skills/README.md](./skills/README.md) for details. | `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.) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..3cf3711 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -114,6 +114,7 @@ | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | +| 🔔 通知 | 列出、已读、删除用户消息 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | | 👤 用户 | 查看用户资料和信息 | @@ -307,6 +308,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 @@ -619,6 +639,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 new file mode 100644 index 0000000..d705279 --- /dev/null +++ b/doc/changes/notification-shortcut.md @@ -0,0 +1,25 @@ +# Notification Shortcut + +## Summary + +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 + +| 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 | + +## 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 0739395..6025e13 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -47,6 +47,10 @@ "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.org.create.short": "Create an organization", "cmd.org.info.short": "Show organization details", "cmd.org.list.short": "List organizations", @@ -173,6 +177,12 @@ "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.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 2e6fc4d..4a1906c 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -47,6 +47,10 @@ "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.org.create.short": "创建组织", "cmd.org.info.short": "显示组织详情", "cmd.org.list.short": "列出组织", @@ -173,6 +177,12 @@ "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.org.id": "组织 ID", "flag.org.id_or_login": "组织 ID 或登录名", "flag.org.name": "组织名称", diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go new file mode 100644 index 0000000..853aa25 --- /dev/null +++ b/shortcuts/notification/notification.go @@ -0,0 +1,239 @@ +package notification + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var messageTypes = map[string]string{ + "notification": "notification", + "atme": "atme", +} + +var listStatuses = map[string]string{ + "unread": "1", + "read": "2", +} + +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: tr.T("cmd.notification.list.short"), + Flags: []common.Flag{ + {Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}, + {Name: "type", Short: "t", Usage: tr.T("flag.notification.type_all"), Default: "all"}, + {Name: "status", Short: "s", Usage: tr.T("flag.notification.status"), Default: "all"}, + {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{ + {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: "delete", + Description: tr.T("cmd.notification.delete.short"), + Flags: []common.Flag{ + {Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}, + {Name: "type", Short: "t", Usage: tr.T("flag.notification.type"), Required: true}, + {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 new file mode 100644 index 0000000..ad2bbd9 --- /dev/null +++ b/shortcuts/notification/notification_test.go @@ -0,0 +1,228 @@ +package notification + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestNotificationListResolvesCurrentUser(t *testing.T) { + requests := 0 + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/users/me.json") + writeJSON(t, w, map[string]interface{}{"login": "mengz"}) + case 2: + assertRequest(t, r, "GET", "/users/mengz/messages.json") + assertEqual(t, r.URL.Query().Get("type"), "notification") + assertEqual(t, r.URL.Query().Get("status"), "1") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "messages": []interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runNotificationShortcut(t, server, "list", map[string]string{ + "type": "notification", + "status": "unread", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestNotificationListUsesExplicitUser(t *testing.T) { + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/users/alice/messages.json") + assertEqual(t, r.URL.Query().Get("page"), "1") + assertEqual(t, r.URL.Query().Get("limit"), "20") + assertEqual(t, r.URL.Query().Get("type"), "") + assertEqual(t, r.URL.Query().Get("status"), "") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "messages": []interface{}{}}) + }) + defer server.Close() + + if err := runNotificationShortcut(t, server, "list", map[string]string{"user": "alice"}); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestNotificationReadPayload(t *testing.T) { + var payload map[string]interface{} + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/users/alice/messages/read.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runNotificationShortcut(t, server, "read", map[string]string{ + "user": "alice", + "type": "atme", + "ids": "1,2,2", + }) + if err != nil { + t.Fatalf("read shortcut failed: %v", err) + } + assertEqual(t, payload["type"], "atme") + assertIntSlice(t, payload["ids"], []int{1, 2}) +} + +func TestNotificationReadAllUnread(t *testing.T) { + var payload map[string]interface{} + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/users/alice/messages/read.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runNotificationShortcut(t, server, "read", map[string]string{ + "user": "alice", + "type": "notification", + "ids": "-1", + }) + if err != nil { + t.Fatalf("read shortcut failed: %v", err) + } + assertIntSlice(t, payload["ids"], []int{-1}) +} + +func TestNotificationDeletePayload(t *testing.T) { + var payload map[string]interface{} + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/users/alice/messages.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runNotificationShortcut(t, server, "delete", map[string]string{ + "user": "alice", + "type": "notification", + "ids": "7,8", + }) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + assertEqual(t, payload["type"], "notification") + assertIntSlice(t, payload["ids"], []int{7, 8}) +} + +func TestNotificationValidation(t *testing.T) { + server := newNotificationTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + shortcut string + args map[string]string + }{ + {name: "invalid type", shortcut: "list", args: map[string]string{"user": "alice", "type": "other"}}, + {name: "invalid status", shortcut: "list", args: map[string]string{"user": "alice", "status": "maybe"}}, + {name: "invalid page", shortcut: "list", args: map[string]string{"user": "alice", "page": "0"}}, + {name: "invalid ids", shortcut: "read", args: map[string]string{"user": "alice", "type": "atme", "ids": "abc"}}, + {name: "delete all unread rejected", shortcut: "delete", args: map[string]string{"user": "alice", "type": "atme", "ids": "-1"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runNotificationShortcut(t, server, tc.shortcut, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func runNotificationShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findNotificationShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findNotificationShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +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") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %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 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 1fedc7e..a1d9497 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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(), + "pipeline": pipeline.Shortcuts(), + "notification": notification.Shortcuts(tr), + "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", + "pipeline": "Pipeline operations", + "notification": tr.T("cmd.notification.short"), + "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 00f4c57..4ee64ce 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", + "dataset", "health", "ignore", "wiki", "notification", } groupSet := map[string]bool{} diff --git a/skills/README.md b/skills/README.md index d507074..931e498 100644 --- a/skills/README.md +++ b/skills/README.md @@ -103,6 +103,8 @@ skills/ │ └── SKILL.md # Pipeline 操作指南 ├── gitlink-wiki/ # Wiki 页面管理 │ └── SKILL.md # Wiki 操作指南 +├── gitlink-notification/ # 用户消息 +│ └── SKILL.md # 消息查询、已读和删除指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -144,6 +146,7 @@ skills/ | **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 | 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`. From cb14f00f3e413ff19aae56a7656dab65e5e2ad56 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 22 Jun 2026 11:13:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(message-settings):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4=E4=BA=A4=E4=BB=98=E8=B4=A8?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + README.zh-CN.md | 24 + internal/i18n/locales/en-US.json | 12 + internal/i18n/locales/zh-CN.json | 12 + shortcuts/messagesetting/messagesetting.go | 877 ++++++++++++++++++ .../messagesetting/messagesetting_test.go | 336 +++++++ shortcuts/register.go | 95 +- 7 files changed, 1311 insertions(+), 46 deletions(-) create mode 100644 shortcuts/messagesetting/messagesetting.go create mode 100644 shortcuts/messagesetting/messagesetting_test.go diff --git a/README.md b/README.md index 694968f..9479fac 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ 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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3cf3711..d06ff16 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,6 +113,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 🔔 消息通知设置 | 查看并更新个人消息通知投递偏好 | | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔔 通知 | 列出、已读、删除用户消息 | | 🔍 搜索 | 搜索仓库、用户 | @@ -269,6 +270,29 @@ gitlink-cli repo +create -n my-project -d "项目描述" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### 消息通知设置 + +```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 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 6025e13..2fc7a0e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -51,6 +51,11 @@ "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", @@ -183,6 +188,13 @@ "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 4a1906c..2c435d4 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -51,6 +51,11 @@ "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": "列出组织", @@ -183,6 +188,13 @@ "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/messagesetting/messagesetting.go b/shortcuts/messagesetting/messagesetting.go new file mode 100644 index 0000000..0552c18 --- /dev/null +++ b/shortcuts/messagesetting/messagesetting.go @@ -0,0 +1,877 @@ +package messagesetting + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + channelNotification = "notification" + channelEmail = "email" + channelBoth = "both" +) + +type catalogResponse struct { + SettingTypes []catalogGroup `json:"setting_types"` +} + +type catalogGroup struct { + Type string `json:"type"` + TypeName string `json:"type_name"` + Settings []catalogSettingRow `json:"settings"` +} + +type catalogSettingRow struct { + Name string `json:"name"` + Key string `json:"key"` + NotificationDisabled bool `json:"notification_disabled"` + EmailDisabled bool `json:"email_disabled"` +} + +type userSettingResponse struct { + User map[string]interface{} `json:"user"` + NotificationBody map[string]bool `json:"notification_body"` + EmailBody map[string]bool `json:"email_body"` +} + +type currentUserResponse struct { + Login string `json:"login"` +} + +type settingMeta struct { + FullKey string + ShortKey string + Group string + GroupName string + Name string + DefaultNotificationEnabled *bool + DefaultEmailEnabled *bool +} + +type settingRegistry struct { + OrderedGroups []string + GroupNames map[string]string + GroupOrder map[string]int + Items map[string]settingMeta +} + +type groupedSettingOutput struct { + Group string `json:"group"` + GroupName string `json:"group_name,omitempty"` + Settings []settingOutput `json:"settings"` + Summary channelSummaryOutput `json:"summary"` +} + +type settingOutput struct { + Key string `json:"key"` + ShortKey string `json:"short_key"` + Name string `json:"name"` + NotificationEnabled bool `json:"notification_enabled"` + EmailEnabled bool `json:"email_enabled"` + DefaultNotificationEnabled *bool `json:"default_notification_enabled,omitempty"` + DefaultEmailEnabled *bool `json:"default_email_enabled,omitempty"` +} + +type channelSummaryOutput struct { + Total int `json:"total"` + NotificationEnabled int `json:"notification_enabled"` + EmailEnabled int `json:"email_enabled"` +} + +type settingChangeOutput struct { + Key string `json:"key"` + ShortKey string `json:"short_key"` + Name string `json:"name"` + Group string `json:"group"` + GroupName string `json:"group_name,omitempty"` + NotificationBefore bool `json:"notification_before"` + NotificationAfter bool `json:"notification_after"` + EmailBefore bool `json:"email_before"` + EmailAfter bool `json:"email_after"` +} + +type presetSpec struct { + Notification bool + Email bool +} + +var allowedPresetNames = map[string]presetSpec{ + "all-on": {Notification: true, Email: true}, + "all-off": {Notification: false, Email: false}, + "notification-only": {Notification: true, Email: false}, + "email-only": {Notification: false, Email: true}, +} + +// Shortcuts returns message settings shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + return []*common.Shortcut{ + { + Name: "catalog", + Description: tr.T("cmd.message_settings.catalog.short"), + Flags: []common.Flag{ + {Name: "group", Usage: tr.T("flag.message_settings.group")}, + }, + Run: runCatalog, + }, + { + Name: "view", + Description: tr.T("cmd.message_settings.view.short"), + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")}, + {Name: "group", Usage: tr.T("flag.message_settings.group")}, + }, + Run: runView, + }, + { + Name: "update", + Description: tr.T("cmd.message_settings.update.short"), + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")}, + {Name: "channel", Usage: tr.T("flag.message_settings.channel"), Required: true}, + {Name: "state", Usage: tr.T("flag.message_settings.state"), Required: true}, + {Name: "keys", Usage: tr.T("flag.message_settings.keys")}, + {Name: "group", Usage: tr.T("flag.message_settings.group")}, + {Name: "all", Usage: tr.T("flag.message_settings.all"), Bool: true, Default: "false"}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, + }, + Run: runUpdate, + }, + { + Name: "preset", + Description: tr.T("cmd.message_settings.preset.short"), + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")}, + {Name: "name", Usage: tr.T("flag.message_settings.preset_name"), Required: true}, + {Name: "keys", Usage: tr.T("flag.message_settings.keys")}, + {Name: "group", Usage: tr.T("flag.message_settings.group")}, + {Name: "all", Usage: tr.T("flag.message_settings.all"), Bool: true, Default: "false"}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, + }, + Run: runPreset, + }, + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +func runCatalog(ctx *common.RuntimeContext) error { + registry, err := loadRegistry(ctx) + if err != nil { + return err + } + + groups, err := parseGroupFilter(ctx.Arg("group"), registry) + if err != nil { + return err + } + + outputGroups := renderCatalogGroups(registry, groups) + return ctx.OutputData(map[string]interface{}{ + "action": "catalog_message_settings", + "groups": outputGroups, + "summary": summarizeCatalogGroups(outputGroups), + }) +} + +func runView(ctx *common.RuntimeContext) error { + login, registry, current, err := loadUserSettingContext(ctx) + if err != nil { + return err + } + + groups, err := parseGroupFilter(ctx.Arg("group"), registry) + if err != nil { + return err + } + + outputGroups := renderUserGroups(registry, current, groups) + return ctx.OutputData(map[string]interface{}{ + "action": "view_message_settings", + "login": login, + "user": current.User, + "groups": outputGroups, + "summary": summarizeUserGroups(outputGroups), + }) +} + +func runUpdate(ctx *common.RuntimeContext) error { + channel, err := parseChannel(ctx.Arg("channel")) + if err != nil { + return err + } + state, err := parseState(ctx.Arg("state")) + if err != nil { + return err + } + + result, err := updateSettings(ctx, updateRequest{ + Action: "update_message_settings", + Channel: channel, + Selector: selectorArgsFromContext(ctx), + DryRun: parseBoolArg(ctx.Arg("dry-run")), + Apply: func(notificationCurrent, emailCurrent bool) (bool, bool) { + nextNotification := notificationCurrent + nextEmail := emailCurrent + switch channel { + case channelNotification: + nextNotification = state + case channelEmail: + nextEmail = state + case channelBoth: + nextNotification = state + nextEmail = state + } + return nextNotification, nextEmail + }, + Extra: map[string]interface{}{ + "channel": channel, + "state": state, + }, + }) + if err != nil { + return err + } + return ctx.OutputData(result) +} + +func runPreset(ctx *common.RuntimeContext) error { + name := strings.ToLower(strings.TrimSpace(ctx.Arg("name"))) + preset, ok := allowedPresetNames[name] + if !ok { + return fmt.Errorf("invalid --name value %q", ctx.Arg("name")) + } + + result, err := updateSettings(ctx, updateRequest{ + Action: "preset_message_settings", + Selector: selectorArgsFromContext(ctx), + DryRun: parseBoolArg(ctx.Arg("dry-run")), + Apply: func(_, _ bool) (bool, bool) { + return preset.Notification, preset.Email + }, + Extra: map[string]interface{}{ + "preset": name, + }, + }) + if err != nil { + return err + } + return ctx.OutputData(result) +} + +type selectorArgs struct { + Keys string + Group string + All bool +} + +type updateRequest struct { + Action string + Channel string + Selector selectorArgs + DryRun bool + Apply func(notificationCurrent, emailCurrent bool) (bool, bool) + Extra map[string]interface{} +} + +func selectorArgsFromContext(ctx *common.RuntimeContext) selectorArgs { + return selectorArgs{ + Keys: ctx.Arg("keys"), + Group: ctx.Arg("group"), + All: parseBoolArg(ctx.Arg("all")), + } +} + +func updateSettings(ctx *common.RuntimeContext, req updateRequest) (map[string]interface{}, error) { + login, registry, current, err := loadUserSettingContext(ctx) + if err != nil { + return nil, err + } + + selectedKeys, selectedGroups, err := selectKeys(req.Selector, registry) + if err != nil { + return nil, err + } + + notificationBody, emailBody := mergedBodies(current, registry) + changes := make([]settingChangeOutput, 0, len(selectedKeys)) + changedKeys := make([]string, 0, len(selectedKeys)) + for _, key := range selectedKeys { + meta := registry.Items[key] + notificationBefore := notificationBody[key] + emailBefore := emailBody[key] + notificationAfter, emailAfter := req.Apply(notificationBefore, emailBefore) + notificationBody[key] = notificationAfter + emailBody[key] = emailAfter + + change := settingChangeOutput{ + Key: key, + ShortKey: meta.ShortKey, + Name: meta.Name, + Group: meta.Group, + GroupName: meta.GroupName, + NotificationBefore: notificationBefore, + NotificationAfter: notificationAfter, + EmailBefore: emailBefore, + EmailAfter: emailAfter, + } + changes = append(changes, change) + if notificationBefore != notificationAfter || emailBefore != emailAfter { + changedKeys = append(changedKeys, key) + } + } + + result := map[string]interface{}{ + "action": req.Action, + "login": login, + "user": current.User, + "dry_run": req.DryRun, + "selected_keys": selectedKeys, + "selected_groups": selectedGroups, + "changed_keys": changedKeys, + "changes": changes, + "summary": map[string]interface{}{ + "selected": len(selectedKeys), + "changed": len(changedKeys), + }, + "setting": map[string]interface{}{ + "notification_body": notificationBody, + "email_body": emailBody, + }, + } + for key, value := range req.Extra { + result[key] = value + } + + if req.DryRun { + return result, nil + } + + payload := map[string]interface{}{ + "setting": map[string]interface{}{ + "notification_body": notificationBody, + "email_body": emailBody, + }, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/api/users/%s/template_message_settings/update_setting", login), payload) + if err != nil { + return nil, err + } + result["updated"] = env.Data + return result, nil +} + +func loadRegistry(ctx *common.RuntimeContext) (*settingRegistry, error) { + catalog, err := fetchCatalog(ctx) + if err != nil { + return nil, err + } + return buildRegistry(catalog, nil), nil +} + +func loadUserSettingContext(ctx *common.RuntimeContext) (string, *settingRegistry, *userSettingResponse, error) { + login, err := resolveTargetLogin(ctx) + if err != nil { + return "", nil, nil, err + } + catalog, err := fetchCatalog(ctx) + if err != nil { + return "", nil, nil, err + } + current, err := fetchUserSettings(ctx, login) + if err != nil { + return "", nil, nil, err + } + return login, buildRegistry(catalog, current), current, nil +} + +func resolveTargetLogin(ctx *common.RuntimeContext) (string, error) { + if login := strings.TrimSpace(ctx.Arg("login")); login != "" { + return login, nil + } + + env, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return "", fmt.Errorf("fetch current user: %w", err) + } + var current currentUserResponse + if err := decodeEnvelopeData(env.Data, ¤t); err != nil { + return "", fmt.Errorf("parse current user: %w", err) + } + if strings.TrimSpace(current.Login) == "" { + return "", fmt.Errorf("current user response did not include a login") + } + return current.Login, nil +} + +func fetchCatalog(ctx *common.RuntimeContext) (*catalogResponse, error) { + env, err := ctx.CallAPI("GET", "/api/template_message_settings", nil) + if err != nil { + return nil, fmt.Errorf("fetch message setting catalog: %w", err) + } + var catalog catalogResponse + if err := decodeEnvelopeData(env.Data, &catalog); err != nil { + return nil, fmt.Errorf("parse message setting catalog: %w", err) + } + return &catalog, nil +} + +func fetchUserSettings(ctx *common.RuntimeContext, login string) (*userSettingResponse, error) { + env, err := ctx.CallAPI("GET", fmt.Sprintf("/api/users/%s/template_message_settings", login), nil) + if err != nil { + return nil, fmt.Errorf("fetch user message settings: %w", err) + } + var response userSettingResponse + if err := decodeEnvelopeData(env.Data, &response); err != nil { + return nil, fmt.Errorf("parse user message settings: %w", err) + } + if response.NotificationBody == nil { + response.NotificationBody = map[string]bool{} + } + if response.EmailBody == nil { + response.EmailBody = map[string]bool{} + } + return &response, nil +} + +func buildRegistry(catalog *catalogResponse, current *userSettingResponse) *settingRegistry { + registry := &settingRegistry{ + OrderedGroups: []string{}, + GroupNames: map[string]string{}, + GroupOrder: map[string]int{}, + Items: map[string]settingMeta{}, + } + + if catalog != nil { + for _, group := range catalog.SettingTypes { + shortGroup := shortenSettingType(group.Type) + if shortGroup == "" { + continue + } + ensureGroup(registry, shortGroup, group.TypeName) + for _, row := range group.Settings { + fullKey := shortGroup + "::" + strings.TrimSpace(row.Key) + defaultNotification := !row.NotificationDisabled + defaultEmail := !row.EmailDisabled + registry.Items[fullKey] = settingMeta{ + FullKey: fullKey, + ShortKey: strings.TrimSpace(row.Key), + Group: shortGroup, + GroupName: group.TypeName, + Name: strings.TrimSpace(row.Name), + DefaultNotificationEnabled: boolPtr(defaultNotification), + DefaultEmailEnabled: boolPtr(defaultEmail), + } + } + } + } + + if current != nil { + for key := range current.NotificationBody { + ensureSettingMeta(registry, key) + } + for key := range current.EmailBody { + ensureSettingMeta(registry, key) + } + } + + return registry +} + +func ensureGroup(registry *settingRegistry, group, name string) { + group = strings.TrimSpace(group) + if group == "" { + return + } + if _, ok := registry.GroupOrder[group]; !ok { + registry.GroupOrder[group] = len(registry.OrderedGroups) + registry.OrderedGroups = append(registry.OrderedGroups, group) + } + if strings.TrimSpace(name) != "" { + registry.GroupNames[group] = strings.TrimSpace(name) + } +} + +func ensureSettingMeta(registry *settingRegistry, fullKey string) { + if _, ok := registry.Items[fullKey]; ok { + return + } + group, shortKey := splitSettingKey(fullKey) + ensureGroup(registry, group, registry.GroupNames[group]) + name := shortKey + if name == "" { + name = fullKey + } + registry.Items[fullKey] = settingMeta{ + FullKey: fullKey, + ShortKey: shortKey, + Group: group, + GroupName: registry.GroupNames[group], + Name: name, + } +} + +func renderCatalogGroups(registry *settingRegistry, filter []string) []groupedSettingOutput { + allowedGroups := makeGroupSet(filter) + groups := make([]groupedSettingOutput, 0, len(registry.OrderedGroups)) + for _, group := range orderedGroups(registry, filter) { + if len(allowedGroups) > 0 && !allowedGroups[group] { + continue + } + settings := collectSettings(registry, group) + items := make([]settingOutput, 0, len(settings)) + summary := channelSummaryOutput{Total: len(settings)} + for _, meta := range settings { + item := settingOutput{ + Key: meta.FullKey, + ShortKey: meta.ShortKey, + Name: meta.Name, + DefaultNotificationEnabled: meta.DefaultNotificationEnabled, + DefaultEmailEnabled: meta.DefaultEmailEnabled, + } + if meta.DefaultNotificationEnabled != nil && *meta.DefaultNotificationEnabled { + item.NotificationEnabled = true + summary.NotificationEnabled++ + } + if meta.DefaultEmailEnabled != nil && *meta.DefaultEmailEnabled { + item.EmailEnabled = true + summary.EmailEnabled++ + } + items = append(items, item) + } + groups = append(groups, groupedSettingOutput{ + Group: group, + GroupName: registry.GroupNames[group], + Settings: items, + Summary: summary, + }) + } + return groups +} + +func renderUserGroups(registry *settingRegistry, current *userSettingResponse, filter []string) []groupedSettingOutput { + notificationBody, emailBody := mergedBodies(current, registry) + allowedGroups := makeGroupSet(filter) + groups := make([]groupedSettingOutput, 0, len(registry.OrderedGroups)) + for _, group := range orderedGroups(registry, filter) { + if len(allowedGroups) > 0 && !allowedGroups[group] { + continue + } + settings := collectSettings(registry, group) + items := make([]settingOutput, 0, len(settings)) + summary := channelSummaryOutput{Total: len(settings)} + for _, meta := range settings { + notificationEnabled := notificationBody[meta.FullKey] + emailEnabled := emailBody[meta.FullKey] + if notificationEnabled { + summary.NotificationEnabled++ + } + if emailEnabled { + summary.EmailEnabled++ + } + items = append(items, settingOutput{ + Key: meta.FullKey, + ShortKey: meta.ShortKey, + Name: meta.Name, + NotificationEnabled: notificationEnabled, + EmailEnabled: emailEnabled, + DefaultNotificationEnabled: meta.DefaultNotificationEnabled, + DefaultEmailEnabled: meta.DefaultEmailEnabled, + }) + } + groups = append(groups, groupedSettingOutput{ + Group: group, + GroupName: registry.GroupNames[group], + Settings: items, + Summary: summary, + }) + } + return groups +} + +func summarizeCatalogGroups(groups []groupedSettingOutput) channelSummaryOutput { + summary := channelSummaryOutput{} + for _, group := range groups { + summary.Total += group.Summary.Total + summary.NotificationEnabled += group.Summary.NotificationEnabled + summary.EmailEnabled += group.Summary.EmailEnabled + } + return summary +} + +func summarizeUserGroups(groups []groupedSettingOutput) channelSummaryOutput { + return summarizeCatalogGroups(groups) +} + +func selectKeys(args selectorArgs, registry *settingRegistry) ([]string, []string, error) { + selected := map[string]bool{} + + groupFilter, err := parseGroupFilter(args.Group, registry) + if err != nil { + return nil, nil, err + } + for _, group := range groupFilter { + for _, meta := range collectSettings(registry, group) { + selected[meta.FullKey] = true + } + } + + keys, err := resolveKeyFilter(args.Keys, registry) + if err != nil { + return nil, nil, err + } + for _, key := range keys { + selected[key] = true + } + + if args.All { + for key := range registry.Items { + selected[key] = true + } + } + + if len(selected) == 0 { + return nil, nil, fmt.Errorf("one of --keys, --group, or --all is required") + } + + selectedKeys := make([]string, 0, len(selected)) + for key := range selected { + selectedKeys = append(selectedKeys, key) + } + sortKeys(registry, selectedKeys) + + selectedGroupsSet := map[string]bool{} + for _, key := range selectedKeys { + selectedGroupsSet[registry.Items[key].Group] = true + } + selectedGroups := make([]string, 0, len(selectedGroupsSet)) + for _, group := range registry.OrderedGroups { + if selectedGroupsSet[group] { + selectedGroups = append(selectedGroups, group) + } + } + return selectedKeys, selectedGroups, nil +} + +func parseGroupFilter(value string, registry *settingRegistry) ([]string, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + + groupLookup := map[string]string{} + for _, group := range registry.OrderedGroups { + groupLookup[strings.ToLower(group)] = group + } + + parts := strings.Split(value, ",") + groups := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + token := strings.ToLower(strings.TrimSpace(part)) + if token == "" { + continue + } + group, ok := groupLookup[token] + if !ok { + return nil, fmt.Errorf("unknown --group value %q", strings.TrimSpace(part)) + } + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + return groups, nil +} + +func resolveKeyFilter(value string, registry *settingRegistry) ([]string, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + + shortLookup := map[string][]string{} + fullLookup := map[string]string{} + for key, meta := range registry.Items { + shortLookup[strings.ToLower(meta.ShortKey)] = append(shortLookup[strings.ToLower(meta.ShortKey)], key) + fullLookup[strings.ToLower(meta.FullKey)] = key + } + + parts := strings.Split(value, ",") + keys := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + token := strings.TrimSpace(part) + if token == "" { + continue + } + + var resolved string + if strings.Contains(token, "::") { + var ok bool + resolved, ok = fullLookup[strings.ToLower(token)] + if !ok { + return nil, fmt.Errorf("unknown --keys value %q", token) + } + } else { + matches := shortLookup[strings.ToLower(token)] + switch len(matches) { + case 0: + return nil, fmt.Errorf("unknown --keys value %q", token) + case 1: + resolved = matches[0] + default: + return nil, fmt.Errorf("ambiguous --keys value %q; use a full Group::Key value", token) + } + } + + if seen[resolved] { + continue + } + seen[resolved] = true + keys = append(keys, resolved) + } + sortKeys(registry, keys) + return keys, nil +} + +func mergedBodies(current *userSettingResponse, registry *settingRegistry) (map[string]bool, map[string]bool) { + notificationBody := map[string]bool{} + emailBody := map[string]bool{} + for key, value := range current.NotificationBody { + notificationBody[key] = value + } + for key, value := range current.EmailBody { + emailBody[key] = value + } + for key, meta := range registry.Items { + if _, ok := notificationBody[key]; !ok { + if meta.DefaultNotificationEnabled != nil { + notificationBody[key] = *meta.DefaultNotificationEnabled + } else { + notificationBody[key] = false + } + } + if _, ok := emailBody[key]; !ok { + if meta.DefaultEmailEnabled != nil { + emailBody[key] = *meta.DefaultEmailEnabled + } else { + emailBody[key] = false + } + } + } + return notificationBody, emailBody +} + +func collectSettings(registry *settingRegistry, group string) []settingMeta { + settings := []settingMeta{} + for _, meta := range registry.Items { + if meta.Group != group { + continue + } + settings = append(settings, meta) + } + sort.Slice(settings, func(i, j int) bool { + return strings.ToLower(settings[i].ShortKey) < strings.ToLower(settings[j].ShortKey) + }) + return settings +} + +func orderedGroups(registry *settingRegistry, filter []string) []string { + if len(filter) == 0 { + return append([]string(nil), registry.OrderedGroups...) + } + return append([]string(nil), filter...) +} + +func sortKeys(registry *settingRegistry, keys []string) { + sort.Slice(keys, func(i, j int) bool { + left := registry.Items[keys[i]] + right := registry.Items[keys[j]] + leftOrder := registry.GroupOrder[left.Group] + rightOrder := registry.GroupOrder[right.Group] + if leftOrder != rightOrder { + return leftOrder < rightOrder + } + return strings.ToLower(left.ShortKey) < strings.ToLower(right.ShortKey) + }) +} + +func parseChannel(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case channelNotification: + return channelNotification, nil + case channelEmail: + return channelEmail, nil + case channelBoth: + return channelBoth, nil + default: + return "", fmt.Errorf("invalid --channel value %q", value) + } +} + +func parseState(value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "on", "true", "enable", "enabled": + return true, nil + case "off", "false", "disable", "disabled": + return false, nil + default: + return false, fmt.Errorf("invalid --state value %q", value) + } +} + +func parseBoolArg(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} + +func splitSettingKey(fullKey string) (string, string) { + parts := strings.SplitN(strings.TrimSpace(fullKey), "::", 2) + if len(parts) != 2 { + return strings.TrimSpace(fullKey), strings.TrimSpace(fullKey) + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +func shortenSettingType(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + parts := strings.Split(value, "::") + return strings.TrimSpace(parts[len(parts)-1]) +} + +func makeGroupSet(groups []string) map[string]bool { + if len(groups) == 0 { + return nil + } + result := map[string]bool{} + for _, group := range groups { + result[group] = true + } + return result +} + +func boolPtr(value bool) *bool { + return &value +} + +func decodeEnvelopeData(data interface{}, target interface{}) error { + raw, err := json.Marshal(data) + if err != nil { + return err + } + return json.Unmarshal(raw, target) +} diff --git a/shortcuts/messagesetting/messagesetting_test.go b/shortcuts/messagesetting/messagesetting_test.go new file mode 100644 index 0000000..9085dea --- /dev/null +++ b/shortcuts/messagesetting/messagesetting_test.go @@ -0,0 +1,336 @@ +package messagesetting + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runMessageSettingShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMessageSettingShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMessageSettingShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeMessageSettingJSON(t *testing.T, w http.ResponseWriter, value interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatalf("encode json: %v", err) + } +} + +func decodeMessageSettingJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + defer r.Body.Close() + var value map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&value); err != nil { + t.Fatalf("decode request body: %v", err) + } + return value +} + +func TestMessageSettingsCatalog(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/template_message_settings.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeMessageSettingJSON(t, w, catalogFixture()) + })) + defer server.Close() + + if err := runMessageSettingShortcut(t, server, "catalog", nil); err != nil { + t.Fatalf("catalog failed: %v", err) + } +} + +func TestMessageSettingsViewUsesCurrentUserWhenLoginMissing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/users/me.json": + writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json": + writeMessageSettingJSON(t, w, catalogFixture()) + case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json": + writeMessageSettingJSON(t, w, userSettingFixture()) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runMessageSettingShortcut(t, server, "view", nil); err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestMessageSettingsUpdateDryRunDoesNotPost(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/users/me.json": + writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json": + writeMessageSettingJSON(t, w, catalogFixture()) + case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json": + writeMessageSettingJSON(t, w, userSettingFixture()) + default: + t.Fatalf("dry-run should not write, got %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runMessageSettingShortcut(t, server, "update", map[string]string{ + "channel": "notification", + "state": "off", + "keys": "Normal::Permission", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("update dry-run failed: %v", err) + } +} + +func TestMessageSettingsUpdatePostsMergedPayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/users/me.json": + writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json": + writeMessageSettingJSON(t, w, catalogFixture()) + case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json": + writeMessageSettingJSON(t, w, userSettingFixture()) + case r.Method == http.MethodPost && r.URL.Path == "/api/users/alice/template_message_settings/update_setting.json": + payload = decodeMessageSettingJSON(t, r) + writeMessageSettingJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runMessageSettingShortcut(t, server, "update", map[string]string{ + "channel": "email", + "state": "on", + "group": "ManageProject", + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + + setting, ok := payload["setting"].(map[string]interface{}) + if !ok { + t.Fatalf("missing setting payload: %#v", payload) + } + notificationBody := setting["notification_body"].(map[string]interface{}) + emailBody := setting["email_body"].(map[string]interface{}) + + if notificationBody["ManageProject::Issue"] != true { + t.Fatalf("notification should stay true for ManageProject::Issue, got %#v", notificationBody["ManageProject::Issue"]) + } + if emailBody["ManageProject::Issue"] != true { + t.Fatalf("email should be enabled for ManageProject::Issue, got %#v", emailBody["ManageProject::Issue"]) + } + if emailBody["Normal::Permission"] != false { + t.Fatalf("email should preserve unrelated keys, got %#v", emailBody["Normal::Permission"]) + } + if notificationBody["ManageProject::Praised"] != true { + t.Fatalf("notification defaults should be preserved for missing keys, got %#v", notificationBody["ManageProject::Praised"]) + } + if emailBody["ManageProject::Praised"] != true { + t.Fatalf("selected group keys should be updated even when missing from current settings, got %#v", emailBody["ManageProject::Praised"]) + } +} + +func TestMessageSettingsPresetPostsSelectedKeys(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/users/me.json": + writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json": + writeMessageSettingJSON(t, w, catalogFixture()) + case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json": + writeMessageSettingJSON(t, w, userSettingFixture()) + case r.Method == http.MethodPost && r.URL.Path == "/api/users/alice/template_message_settings/update_setting.json": + payload = decodeMessageSettingJSON(t, r) + writeMessageSettingJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runMessageSettingShortcut(t, server, "preset", map[string]string{ + "name": "all-off", + "keys": "Permission,ManageProject::Issue", + "all": "false", + }) + if err != nil { + t.Fatalf("preset failed: %v", err) + } + + setting := payload["setting"].(map[string]interface{}) + notificationBody := setting["notification_body"].(map[string]interface{}) + emailBody := setting["email_body"].(map[string]interface{}) + + if notificationBody["Normal::Permission"] != false || emailBody["Normal::Permission"] != false { + t.Fatalf("Normal::Permission should be turned off by preset") + } + if notificationBody["ManageProject::Issue"] != false || emailBody["ManageProject::Issue"] != false { + t.Fatalf("ManageProject::Issue should be turned off by preset") + } + if notificationBody["ManageProject::PullRequest"] != true { + t.Fatalf("unselected keys should remain unchanged, got %#v", notificationBody["ManageProject::PullRequest"]) + } +} + +func TestSelectKeysRequiresSelector(t *testing.T) { + registry := buildRegistry(mustCatalogResponse(t, catalogFixture()), mustUserSettingResponse(t, userSettingFixture())) + if _, _, err := selectKeys(selectorArgs{}, registry); err == nil { + t.Fatal("expected selector validation error") + } +} + +func TestParseGroupFilterRejectsUnknownGroup(t *testing.T) { + registry := buildRegistry(mustCatalogResponse(t, catalogFixture()), nil) + if _, err := parseGroupFilter("UnknownGroup", registry); err == nil { + t.Fatal("expected unknown group error") + } +} + +func TestResolveKeyFilterRejectsAmbiguousShortKey(t *testing.T) { + catalog := &catalogResponse{ + SettingTypes: []catalogGroup{ + { + Type: "TemplateMessageSetting::Normal", + TypeName: "Normal", + Settings: []catalogSettingRow{ + {Name: "Issue", Key: "Issue"}, + }, + }, + { + Type: "TemplateMessageSetting::ManageProject", + TypeName: "Manage", + Settings: []catalogSettingRow{ + {Name: "Issue", Key: "Issue"}, + }, + }, + }, + } + registry := buildRegistry(catalog, nil) + if _, err := resolveKeyFilter("Issue", registry); err == nil { + t.Fatal("expected ambiguous key error") + } +} + +func catalogFixture() map[string]interface{} { + return map[string]interface{}{ + "setting_types": []map[string]interface{}{ + { + "type": "TemplateMessageSetting::Normal", + "type_name": "My status", + "settings": []map[string]interface{}{ + { + "name": "Permission changed", + "key": "Permission", + "notification_disabled": false, + "email_disabled": false, + }, + }, + }, + { + "type": "TemplateMessageSetting::ManageProject", + "type_name": "Managed repositories", + "settings": []map[string]interface{}{ + { + "name": "New issue", + "key": "Issue", + "notification_disabled": false, + "email_disabled": false, + }, + { + "name": "New pull request", + "key": "PullRequest", + "notification_disabled": false, + "email_disabled": false, + }, + { + "name": "Praised", + "key": "Praised", + "notification_disabled": false, + "email_disabled": true, + }, + }, + }, + }, + } +} + +func userSettingFixture() map[string]interface{} { + return map[string]interface{}{ + "user": map[string]interface{}{ + "login": "alice", + "name": "Alice", + }, + "notification_body": map[string]interface{}{ + "Normal::Permission": true, + "ManageProject::Issue": true, + "ManageProject::PullRequest": true, + }, + "email_body": map[string]interface{}{ + "Normal::Permission": false, + "ManageProject::Issue": false, + "ManageProject::PullRequest": false, + }, + } +} + +func mustCatalogResponse(t *testing.T, value map[string]interface{}) *catalogResponse { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal catalog fixture: %v", err) + } + var result catalogResponse + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal catalog fixture: %v", err) + } + return &result +} + +func mustUserSettingResponse(t *testing.T, value map[string]interface{}) *userSettingResponse { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal user setting fixture: %v", err) + } + var result userSettingResponse + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal user setting fixture: %v", err) + } + return &result +} diff --git a/shortcuts/register.go b/shortcuts/register.go index a1d9497..e0f06b8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -15,6 +15,7 @@ import ( "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" @@ -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(), - "notification": notification.Shortcuts(tr), - "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(), + "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", - "notification": tr.T("cmd.notification.short"), - "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", + "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 { From 071dd241a05ddd81ef74417863bd7015c4996a1f Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 22 Jun 2026 13:03:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(shortcuts):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83=E5=88=86=E7=BB=84=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/register_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 4ee64ce..a127c5f 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -13,7 +13,7 @@ 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", + "compare", "member", "message-settings", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", "notification", }