From 365a42e790f277297ff91d30fc522b76e50c03b4 Mon Sep 17 00:00:00 2001
From: Mengz <2567587994@qq.com>
Date: Wed, 10 Jun 2026 17:48:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(message):=20=E5=A2=9E=E5=8A=A0=E6=B6=88?=
=?UTF-8?q?=E6=81=AF=E4=B8=AD=E5=BF=83=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 18 ++
doc/changes/message-shortcuts.md | 25 ++
shortcuts/message/message.go | 418 ++++++++++++++++++++++++++++++
shortcuts/message/message_test.go | 291 +++++++++++++++++++++
shortcuts/register.go | 3 +
shortcuts/register_test.go | 2 +-
6 files changed, 756 insertions(+), 1 deletion(-)
create mode 100644 doc/changes/message-shortcuts.md
create mode 100644 shortcuts/message/message.go
create mode 100644 shortcuts/message/message_test.go
diff --git a/README.md b/README.md
index b43dcd1..0cb79bf 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
+The `message` shortcut group adds inbox automation for listing messages, checking unread counters, marking messages as read, and deleting older notifications.
+
## Installation & Quick Start
### Requirements
@@ -285,6 +287,22 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
+### Message Center Management
+
+```bash
+# List the latest notifications with unread filtering
+gitlink-cli message +list --login Mengz --type notification --status unread --limit 10
+
+# Show unread counters for notifications and @mentions
+gitlink-cli message +stats --login Mengz
+
+# Preview marking all notifications as read
+gitlink-cli message +read --login Mengz --type notification --all --dry-run
+
+# Delete selected @mention messages
+gitlink-cli message +delete --login Mengz --type atme --ids 101,102
+```
+
### Issue Management
```bash
diff --git a/doc/changes/message-shortcuts.md b/doc/changes/message-shortcuts.md
new file mode 100644
index 0000000..03c9138
--- /dev/null
+++ b/doc/changes/message-shortcuts.md
@@ -0,0 +1,25 @@
+# Message Center Shortcuts
+
+## Summary
+
+This change adds a new `message` shortcut group for personal inbox management in GitLink CLI.
+It covers message listing, unread counters, batch mark-as-read, and batch delete workflows without forcing users to drop down to raw API calls.
+
+## Included Commands
+
+- `message +list` filters inbox items by message type, read status, page, and limit.
+- `message +stats` returns unread counters for notifications and `@me` messages.
+- `message +read` marks selected message IDs, or all unread messages of a given type, as read.
+- `message +delete` deletes selected message IDs, or all unread messages of a given type.
+
+## Usability Details
+
+- `--login` defaults to the authenticated user when omitted.
+- `--dry-run` is supported for write operations so users can inspect destructive requests first.
+- `content_text` is added to list output to expose HTML-free plain text that is easier to grep, diff, and script.
+
+## Validation
+
+- Added shortcut tests for list, stats, read, delete, ID parsing, and content normalization.
+- Verified registration by wiring the `message` group into the global shortcut registry.
+- Updated `README.md` with feature coverage and usage examples.
diff --git a/shortcuts/message/message.go b/shortcuts/message/message.go
new file mode 100644
index 0000000..e7589c8
--- /dev/null
+++ b/shortcuts/message/message.go
@@ -0,0 +1,418 @@
+package message
+
+import (
+ "encoding/json"
+ "fmt"
+ "html"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+const (
+ messageTypeNotification = "notification"
+ messageTypeAtme = "atme"
+ messageTypeAll = "all"
+
+ messageStatusAll = "all"
+ messageStatusUnread = "unread"
+ messageStatusRead = "read"
+)
+
+var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
+
+type currentUserResponse struct {
+ Login string `json:"login"`
+}
+
+type listResponse struct {
+ TotalCount int `json:"total_count"`
+ Type string `json:"type"`
+ UnreadNotification int `json:"unread_notification"`
+ UnreadAtme int `json:"unread_atme"`
+ Messages []messageRow `json:"messages"`
+}
+
+type messageRow struct {
+ ID int64 `json:"id"`
+ Status int `json:"status"`
+ Content string `json:"content"`
+ NotificationURL string `json:"notification_url"`
+ Source string `json:"source"`
+ CreatedAt string `json:"created_at"`
+ TimeAgo string `json:"time_ago"`
+ Type string `json:"type"`
+ Sender map[string]interface{} `json:"sender"`
+}
+
+type messageOutput struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ Status int `json:"status"`
+ Read bool `json:"read"`
+ Source string `json:"source,omitempty"`
+ Content string `json:"content"`
+ ContentText string `json:"content_text"`
+ NotificationURL string `json:"notification_url,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ TimeAgo string `json:"time_ago,omitempty"`
+ Sender map[string]interface{} `json:"sender,omitempty"`
+}
+
+// Shortcuts returns message management shortcuts.
+func Shortcuts() []*common.Shortcut {
+ return []*common.Shortcut{
+ {
+ Name: "list",
+ Description: "List user messages with filters and plain-text content",
+ Flags: []common.Flag{
+ {Name: "login", Short: "l", Usage: "Target user login (defaults to current authenticated user)"},
+ {Name: "type", Short: "t", Usage: "Message type: notification, atme, or all", Default: messageTypeAll},
+ {Name: "status", Usage: "Message status: unread, read, or all", Default: messageStatusAll},
+ {Name: "page", Usage: "Page number", Default: "1"},
+ {Name: "limit", Usage: "Items per page", Default: "20"},
+ },
+ Run: runList,
+ },
+ {
+ Name: "stats",
+ Description: "Show unread message counters for a user",
+ Flags: []common.Flag{
+ {Name: "login", Short: "l", Usage: "Target user login (defaults to current authenticated user)"},
+ {Name: "type", Short: "t", Usage: "Message type: notification, atme, or all", Default: messageTypeAll},
+ },
+ Run: runStats,
+ },
+ {
+ Name: "read",
+ Description: "Mark selected messages as read",
+ Flags: []common.Flag{
+ {Name: "login", Short: "l", Usage: "Target user login (defaults to current authenticated user)"},
+ {Name: "type", Short: "t", Usage: "Message type: notification or atme", Required: true},
+ {Name: "ids", Usage: "Comma-separated message IDs"},
+ {Name: "all", Usage: "Mark all unread messages of the selected type as read", Bool: true, Default: "false"},
+ {Name: "dry-run", Usage: "Preview the request without sending it", Bool: true, Default: "false"},
+ },
+ Run: runRead,
+ },
+ {
+ Name: "delete",
+ Description: "Delete selected messages",
+ Flags: []common.Flag{
+ {Name: "login", Short: "l", Usage: "Target user login (defaults to current authenticated user)"},
+ {Name: "type", Short: "t", Usage: "Message type: notification or atme", Required: true},
+ {Name: "ids", Usage: "Comma-separated message IDs"},
+ {Name: "all", Usage: "Delete all unread messages of the selected type", Bool: true, Default: "false"},
+ {Name: "dry-run", Usage: "Preview the request without sending it", Bool: true, Default: "false"},
+ },
+ Run: runDelete,
+ },
+ }
+}
+
+func runList(ctx *common.RuntimeContext) error {
+ login, err := resolveTargetLogin(ctx)
+ if err != nil {
+ return err
+ }
+ query, messageType, status, page, limit, err := buildListQuery(ctx.Arg("type"), ctx.Arg("status"), ctx.Arg("page"), ctx.Arg("limit"))
+ if err != nil {
+ return err
+ }
+ response, err := fetchMessages(ctx, login, query)
+ if err != nil {
+ return err
+ }
+
+ return ctx.OutputData(map[string]interface{}{
+ "action": "list_messages",
+ "login": login,
+ "type": messageType,
+ "status": status,
+ "page": page,
+ "limit": limit,
+ "total_count": response.TotalCount,
+ "unread_notification": response.UnreadNotification,
+ "unread_atme": response.UnreadAtme,
+ "messages": normalizeMessages(response.Messages),
+ })
+}
+
+func runStats(ctx *common.RuntimeContext) error {
+ login, err := resolveTargetLogin(ctx)
+ if err != nil {
+ return err
+ }
+ messageType, err := parseListType(ctx.Arg("type"))
+ if err != nil {
+ return err
+ }
+
+ query := url.Values{}
+ query.Set("page", "1")
+ query.Set("limit", "1")
+ if messageType != messageTypeAll {
+ query.Set("type", messageType)
+ }
+
+ response, err := fetchMessages(ctx, login, query)
+ if err != nil {
+ return err
+ }
+
+ return ctx.OutputData(map[string]interface{}{
+ "action": "message_stats",
+ "login": login,
+ "type": messageType,
+ "total_count": response.TotalCount,
+ "unread_notification": response.UnreadNotification,
+ "unread_atme": response.UnreadAtme,
+ "unread_total": response.UnreadNotification + response.UnreadAtme,
+ })
+}
+
+func runRead(ctx *common.RuntimeContext) error {
+ return mutateMessages(ctx, "read_messages", httpMutation{
+ Method: "POST",
+ PathSuffix: "/read",
+ })
+}
+
+func runDelete(ctx *common.RuntimeContext) error {
+ return mutateMessages(ctx, "delete_messages", httpMutation{
+ Method: "DELETE",
+ PathSuffix: "",
+ })
+}
+
+type httpMutation struct {
+ Method string
+ PathSuffix string
+}
+
+func mutateMessages(ctx *common.RuntimeContext, action string, mutation httpMutation) error {
+ login, err := resolveTargetLogin(ctx)
+ if err != nil {
+ return err
+ }
+ messageType, err := parseMutationType(ctx.Arg("type"))
+ if err != nil {
+ return err
+ }
+ ids, mode, err := parseMutationIDs(ctx.Arg("ids"), parseBoolArg(ctx.Arg("all")))
+ if err != nil {
+ return err
+ }
+
+ result := map[string]interface{}{
+ "action": action,
+ "login": login,
+ "type": messageType,
+ "mode": mode,
+ "ids": ids,
+ "dry_run": parseBoolArg(ctx.Arg("dry-run")),
+ "item_count": len(ids),
+ }
+ if mode == "all" {
+ result["item_count"] = "all"
+ }
+
+ if parseBoolArg(ctx.Arg("dry-run")) {
+ return ctx.OutputData(result)
+ }
+
+ payload := map[string]interface{}{
+ "type": messageType,
+ "ids": ids,
+ }
+ env, err := ctx.CallAPI(mutation.Method, fmt.Sprintf("/api/users/%s/messages%s", login, mutation.PathSuffix), payload)
+ if err != nil {
+ return err
+ }
+ result["updated"] = env.Data
+ return ctx.OutputData(result)
+}
+
+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 buildListQuery(typeValue, statusValue, pageValue, limitValue string) (url.Values, string, string, int, int, error) {
+ messageType, err := parseListType(typeValue)
+ if err != nil {
+ return nil, "", "", 0, 0, err
+ }
+ status, statusCode, err := parseListStatus(statusValue)
+ if err != nil {
+ return nil, "", "", 0, 0, err
+ }
+ page, err := parsePositiveInt(pageValue, "page")
+ if err != nil {
+ return nil, "", "", 0, 0, err
+ }
+ limit, err := parsePositiveInt(limitValue, "limit")
+ if err != nil {
+ return nil, "", "", 0, 0, err
+ }
+
+ query := url.Values{}
+ query.Set("page", strconv.Itoa(page))
+ query.Set("limit", strconv.Itoa(limit))
+ if messageType != messageTypeAll {
+ query.Set("type", messageType)
+ }
+ if statusCode != 0 {
+ query.Set("status", strconv.Itoa(statusCode))
+ }
+ return query, messageType, status, page, limit, nil
+}
+
+func fetchMessages(ctx *common.RuntimeContext, login string, query url.Values) (*listResponse, error) {
+ env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/api/users/%s/messages", login), query)
+ if err != nil {
+ return nil, fmt.Errorf("fetch messages: %w", err)
+ }
+ var response listResponse
+ if err := decodeEnvelopeData(env.Data, &response); err != nil {
+ return nil, fmt.Errorf("parse message list: %w", err)
+ }
+ return &response, nil
+}
+
+func normalizeMessages(rows []messageRow) []messageOutput {
+ items := make([]messageOutput, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, messageOutput{
+ ID: row.ID,
+ Type: row.Type,
+ Status: row.Status,
+ Read: row.Status == 2,
+ Source: row.Source,
+ Content: row.Content,
+ ContentText: normalizeMessageText(row.Content),
+ NotificationURL: row.NotificationURL,
+ CreatedAt: row.CreatedAt,
+ TimeAgo: row.TimeAgo,
+ Sender: row.Sender,
+ })
+ }
+ return items
+}
+
+func normalizeMessageText(value string) string {
+ value = htmlTagPattern.ReplaceAllString(value, " ")
+ value = html.UnescapeString(value)
+ return strings.Join(strings.Fields(value), " ")
+}
+
+func parseListType(value string) (string, error) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ value = messageTypeAll
+ }
+ switch value {
+ case messageTypeAll, messageTypeNotification, messageTypeAtme:
+ return value, nil
+ default:
+ return "", fmt.Errorf("invalid --type value %q", value)
+ }
+}
+
+func parseMutationType(value string) (string, error) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ switch value {
+ case messageTypeNotification, messageTypeAtme:
+ return value, nil
+ default:
+ return "", fmt.Errorf("invalid --type value %q", value)
+ }
+}
+
+func parseListStatus(value string) (string, int, error) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ value = messageStatusAll
+ }
+ switch value {
+ case messageStatusAll:
+ return value, 0, nil
+ case messageStatusUnread:
+ return value, 1, nil
+ case messageStatusRead:
+ return value, 2, nil
+ default:
+ return "", 0, fmt.Errorf("invalid --status value %q", value)
+ }
+}
+
+func parsePositiveInt(value, name string) (int, error) {
+ parsed, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil || parsed <= 0 {
+ return 0, fmt.Errorf("invalid --%s value %q", name, value)
+ }
+ return parsed, nil
+}
+
+func parseMutationIDs(value string, all bool) ([]int64, string, error) {
+ if all {
+ if strings.TrimSpace(value) != "" {
+ return nil, "", fmt.Errorf("--ids cannot be used together with --all")
+ }
+ return []int64{-1}, "all", nil
+ }
+
+ parts := strings.Split(strings.TrimSpace(value), ",")
+ ids := make([]int64, 0, len(parts))
+ seen := map[int64]bool{}
+ for _, part := range parts {
+ token := strings.TrimSpace(part)
+ if token == "" {
+ continue
+ }
+ id, err := strconv.ParseInt(token, 10, 64)
+ if err != nil || id <= 0 {
+ return nil, "", fmt.Errorf("invalid --ids value %q", token)
+ }
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ if len(ids) == 0 {
+ return nil, "", fmt.Errorf("one of --ids or --all is required")
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ return ids, "selected", nil
+}
+
+func parseBoolArg(value string) bool {
+ return strings.EqualFold(strings.TrimSpace(value), "true")
+}
+
+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/message/message_test.go b/shortcuts/message/message_test.go
new file mode 100644
index 0000000..cabba82
--- /dev/null
+++ b/shortcuts/message/message_test.go
@@ -0,0 +1,291 @@
+package message
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/gitlink-org/gitlink-cli/internal/client"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func runMessageShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
+ t.Helper()
+ shortcut := findMessageShortcut(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 findMessageShortcut(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 writeMessageJSON(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 decodeMessageJSON(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 TestMessageListUsesCurrentUser(t *testing.T) {
+ var sawList bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/users/me.json":
+ writeMessageJSON(t, w, map[string]interface{}{"login": "alice"})
+ case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/messages.json":
+ sawList = true
+ if got := r.URL.Query().Get("type"); got != messageTypeNotification {
+ t.Fatalf("type query = %q, want %q", got, messageTypeNotification)
+ }
+ if got := r.URL.Query().Get("status"); got != "1" {
+ t.Fatalf("status query = %q, want 1", got)
+ }
+ if got := r.URL.Query().Get("page"); got != "2" {
+ t.Fatalf("page query = %q, want 2", got)
+ }
+ if got := r.URL.Query().Get("limit"); got != "5" {
+ t.Fatalf("limit query = %q, want 5", got)
+ }
+ writeMessageJSON(t, w, messageListFixture())
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ }))
+ defer server.Close()
+
+ err := runMessageShortcut(t, server, "list", map[string]string{
+ "type": messageTypeNotification,
+ "status": messageStatusUnread,
+ "page": "2",
+ "limit": "5",
+ })
+ if err != nil {
+ t.Fatalf("list failed: %v", err)
+ }
+ if !sawList {
+ t.Fatal("expected message list request to be sent")
+ }
+}
+
+func TestMessageStatsUsesSelectedLogin(t *testing.T) {
+ var sawStats bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/users/alice/messages.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ sawStats = true
+ if got := r.URL.Query().Get("page"); got != "1" {
+ t.Fatalf("page query = %q, want 1", got)
+ }
+ if got := r.URL.Query().Get("limit"); got != "1" {
+ t.Fatalf("limit query = %q, want 1", got)
+ }
+ if got := r.URL.Query().Get("type"); got != messageTypeAtme {
+ t.Fatalf("type query = %q, want %q", got, messageTypeAtme)
+ }
+ writeMessageJSON(t, w, messageListFixture())
+ }))
+ defer server.Close()
+
+ err := runMessageShortcut(t, server, "stats", map[string]string{
+ "login": "alice",
+ "type": messageTypeAtme,
+ })
+ if err != nil {
+ t.Fatalf("stats failed: %v", err)
+ }
+ if !sawStats {
+ t.Fatal("expected message stats request to be sent")
+ }
+}
+
+func TestMessageReadDryRunDoesNotWrite(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatalf("dry-run should not write, got %s %s", r.Method, r.URL.String())
+ }))
+ defer server.Close()
+
+ err := runMessageShortcut(t, server, "read", map[string]string{
+ "login": "alice",
+ "type": messageTypeNotification,
+ "ids": "101,202",
+ "dry-run": "true",
+ })
+ if err != nil {
+ t.Fatalf("read dry-run failed: %v", err)
+ }
+}
+
+func TestMessageReadMarksAllUnread(t *testing.T) {
+ var payload map[string]interface{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/users/alice/messages/read.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ payload = decodeMessageJSON(t, r)
+ writeMessageJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"})
+ }))
+ defer server.Close()
+
+ err := runMessageShortcut(t, server, "read", map[string]string{
+ "login": "alice",
+ "type": messageTypeNotification,
+ "all": "true",
+ })
+ if err != nil {
+ t.Fatalf("read failed: %v", err)
+ }
+
+ if payload["type"] != messageTypeNotification {
+ t.Fatalf("type = %#v, want %q", payload["type"], messageTypeNotification)
+ }
+ ids := payload["ids"].([]interface{})
+ if len(ids) != 1 || ids[0].(float64) != -1 {
+ t.Fatalf("ids = %#v, want [-1]", ids)
+ }
+}
+
+func TestMessageDeletePostsSelectedIDs(t *testing.T) {
+ var payload map[string]interface{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || r.URL.Path != "/api/users/alice/messages.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
+ }
+ payload = decodeMessageJSON(t, r)
+ writeMessageJSON(t, w, map[string]interface{}{"status": 0, "message": "deleted"})
+ }))
+ defer server.Close()
+
+ err := runMessageShortcut(t, server, "delete", map[string]string{
+ "login": "alice",
+ "type": messageTypeAtme,
+ "ids": "202,101,202",
+ })
+ if err != nil {
+ t.Fatalf("delete failed: %v", err)
+ }
+
+ if payload["type"] != messageTypeAtme {
+ t.Fatalf("type = %#v, want %q", payload["type"], messageTypeAtme)
+ }
+ ids := payload["ids"].([]interface{})
+ if len(ids) != 2 || ids[0].(float64) != 101 || ids[1].(float64) != 202 {
+ t.Fatalf("ids = %#v, want [101 202]", ids)
+ }
+}
+
+func TestParseMutationIDsRejectsInvalidInput(t *testing.T) {
+ if _, _, err := parseMutationIDs("", false); err == nil {
+ t.Fatal("expected error when neither --ids nor --all is provided")
+ }
+ if _, _, err := parseMutationIDs("1,abc", false); err == nil {
+ t.Fatal("expected error for invalid message id")
+ }
+ if _, _, err := parseMutationIDs("1", true); err == nil {
+ t.Fatal("expected error when --ids and --all are used together")
+ }
+}
+
+func TestNormalizeMessageText(t *testing.T) {
+ got := normalizeMessageText("someone @you & team")
+ want := "someone @you & team"
+ if got != want {
+ t.Fatalf("normalizeMessageText() = %q, want %q", got, want)
+ }
+}
+
+func TestNormalizeMessagesIncludesPlainTextContent(t *testing.T) {
+ got := normalizeMessages([]messageRow{
+ {
+ ID: 101,
+ Type: messageTypeNotification,
+ Status: 1,
+ Content: "merged successfully",
+ NotificationURL: "https://example.com/pulls/1",
+ Source: "PullRequestMerged",
+ CreatedAt: "2026-06-10 12:00:00",
+ TimeAgo: "1 hour ago",
+ Sender: map[string]interface{}{
+ "login": "alice",
+ },
+ },
+ })
+ want := []messageOutput{
+ {
+ ID: 101,
+ Type: messageTypeNotification,
+ Status: 1,
+ Read: false,
+ Source: "PullRequestMerged",
+ Content: "merged successfully",
+ ContentText: "merged successfully",
+ NotificationURL: "https://example.com/pulls/1",
+ CreatedAt: "2026-06-10 12:00:00",
+ TimeAgo: "1 hour ago",
+ Sender: map[string]interface{}{
+ "login": "alice",
+ },
+ },
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("normalizeMessages() = %#v, want %#v", got, want)
+ }
+}
+
+func messageListFixture() map[string]interface{} {
+ return map[string]interface{}{
+ "total_count": 2,
+ "type": messageTypeNotification,
+ "unread_notification": 1,
+ "unread_atme": 3,
+ "messages": []map[string]interface{}{
+ {
+ "id": 101,
+ "status": 1,
+ "content": "your pull request was merged",
+ "notification_url": "https://example.com/pulls/1",
+ "source": "PullRequestMerged",
+ "created_at": "2026-06-10 12:00:00",
+ "time_ago": "1 hour ago",
+ "type": messageTypeNotification,
+ },
+ {
+ "id": 202,
+ "status": 2,
+ "content": "someone @you",
+ "type": messageTypeAtme,
+ "sender": map[string]interface{}{
+ "login": "bob",
+ },
+ },
+ },
+ }
+}
diff --git a/shortcuts/register.go b/shortcuts/register.go
index 917bf85..691e63b 100644
--- a/shortcuts/register.go
+++ b/shortcuts/register.go
@@ -13,6 +13,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/message"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
@@ -37,6 +38,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
+ "message": message.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
@@ -58,6 +60,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
+ "message": "Message center operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go
index a8c8ce4..43dfa7d 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", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
- "health",
+ "health", "message",
}
groupSet := map[string]bool{}