feat(message): 增加消息中心快捷命令

This commit is contained in:
Mengz 2026-06-10 17:48:14 +08:00
parent 52b7093846
commit 365a42e790
6 changed files with 756 additions and 1 deletions

View File

@ -111,6 +111,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 📋 PM | Sprint management, kanban boards, weekly reports | | 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes | | 🤖 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 ## Installation & Quick Start
### Requirements ### 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 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 ### Issue Management
```bash ```bash

View File

@ -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.

View File

@ -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, &current); 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)
}

View File

@ -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 <b>@you</b> &amp; <i>team</i>")
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 <b>successfully</b>",
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 <b>successfully</b>",
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 <b>merged</b>",
"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 <b>@you</b>",
"type": messageTypeAtme,
"sender": map[string]interface{}{
"login": "bob",
},
},
},
}
}

View File

@ -13,6 +13,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license" "github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member" "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/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
@ -37,6 +38,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"label": label.Shortcuts(), "label": label.Shortcuts(),
"license": license.Shortcuts(), "license": license.Shortcuts(),
"member": member.Shortcuts(), "member": member.Shortcuts(),
"message": message.Shortcuts(),
"milestone": milestone.Shortcuts(), "milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(), "pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr), "pr": pr.Shortcuts(tr),
@ -58,6 +60,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"label": "Issue label operations", "label": "Issue label operations",
"license": "License operations", "license": "License operations",
"member": "Repository member operations", "member": "Repository member operations",
"message": "Message center operations",
"milestone": "Milestone operations", "milestone": "Milestone operations",
"pipeline": "Pipeline operations", "pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"), "pr": tr.T("cmd.pr.short"),

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "release", "branch", "repo", "issue", "label", "license", "pr", "release", "branch",
"org", "user", "search", "ci", "workflow", "org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook", "compare", "member", "milestone", "pipeline", "webhook",
"health", "health", "message",
} }
groupSet := map[string]bool{} groupSet := map[string]bool{}