diff --git a/README.md b/README.md index 9398200..0cb79bf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). **[中文文档](./README.zh-CN.md)** @@ -78,19 +78,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans jiangtx
jiangtx -
- luwanzhou -
luwanzhou -
-
- whale_hihihi -
whale_hihihi -
## Why gitlink-cli? -- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup +- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup - **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded @@ -104,7 +96,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| | 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | -| 🐛 Issue | Create, update, close, batch close/update/delete/reopen/label/assign/comment/export/import, comment on issues | +| 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | @@ -115,12 +107,12 @@ 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 | | 🔍 Search | Search repositories, users | -| 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | -| 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | +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 @@ -249,30 +241,11 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 -# List and update repository navigation units -gitlink-cli repo +units --owner Gitlink --repo forgeplus -gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki - # Create a repository gitlink-cli repo +create -n my-project -d "Project description" # Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus - -# List organizations that can receive a repository transfer -gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus - -# Preview a repository transfer without changing data -gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run - -# Confirm and send a repository transfer request -gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes - -# Preview canceling a pending repository transfer -gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run - -# Confirm canceling a pending repository transfer -gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes ``` ### Webhook Management @@ -314,17 +287,20 @@ 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 ``` -### Attachment Operations +### Message Center Management ```bash -# Upload a local file -gitlink-cli attachment +upload -f ./build.log -d "CI build log" +# List the latest notifications with unread filtering +gitlink-cli message +list --login Mengz --type notification --status unread --limit 10 -# Upload a file and attach container metadata -gitlink-cli attachment +upload -f ./release-notes.md --container-id 42 --container-type VersionRelease +# Show unread counters for notifications and @mentions +gitlink-cli message +stats --login Mengz -# Delete an uploaded attachment -gitlink-cli attachment +delete -i 791eccbf-2e35-4301-ad95-8c937a117f40 +# 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 @@ -354,38 +330,6 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 # Batch close issues from a CSV file gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv -# Preview batch metadata update by API issue IDs -# Note: --ids uses API issue IDs, not web URL issue numbers. -gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run - -# Destructive batch delete requires both dry-run first and --yes for real execution -gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run -gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes - -# Batch reopen closed issues by issue numbers -gitlink-cli issue +batch-reopen --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run -gitlink-cli issue +batch-reopen --owner Gitlink --repo forgeplus --from issues.csv - -# Batch add/remove labels by API issue IDs (requires numeric tag IDs) -gitlink-cli issue +batch-label --owner Gitlink --repo forgeplus --ids 101,102 --add 1,2 --dry-run -gitlink-cli issue +batch-label --owner Gitlink --repo forgeplus --ids 101,102 --remove 3 - -# Batch assign/unassign users by API issue IDs (requires numeric user IDs) -gitlink-cli issue +batch-assign --owner Gitlink --repo forgeplus --ids 101,102 --add 5,6 --dry-run -gitlink-cli issue +batch-assign --owner Gitlink --repo forgeplus --ids 101,102 --remove 7 - -# Batch add comments to multiple issues by issue numbers -gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 123,124 --message "Batch update notice" --dry-run -gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --from issues.csv --message "Processed" - -# Export issues to CSV or JSON with filters -gitlink-cli issue +batch-export --owner Gitlink --repo forgeplus --state open --format csv --output issues.csv -gitlink-cli issue +batch-export --owner Gitlink --repo forgeplus --state closed --keyword bug --format json --output closed_issues.json - -# Import issues from a CSV file -gitlink-cli issue +batch-import --owner Gitlink --repo forgeplus --from new_issues.csv --dry-run -gitlink-cli issue +batch-import --owner Gitlink --repo forgeplus --from new_issues.csv - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" @@ -541,16 +485,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` -### Ignore File Templates - -```bash -# List all available .gitignore templates -gitlink-cli ignore +list - -# Filter templates by name -gitlink-cli ignore +list --name Go -``` - ### Search ```bash @@ -561,29 +495,6 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` -### User Profile - -`profile` surfaces GitLink's native user statistics (ability, role, major, activity, -contribution). When `--user` is omitted it defaults to the authenticated user. - -```bash -# Development ability scores + language breakdown -gitlink-cli profile +ability --user zhangsan - -# Role positioning / major (discipline) categories -gitlink-cli profile +role --user zhangsan -gitlink-cli profile +major --user zhangsan - -# Ability within a time window (Unix timestamps) -gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600 - -# Recent activity (issues / PRs / commits per day) for the current user -gitlink-cli profile +activity - -# Contribution heatmap for a given year -gitlink-cli profile +contribution --user zhangsan --year 2025 -``` - ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -683,32 +594,6 @@ Safety: - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. -### Dataset - -`dataset` manages and queries GitLink research datasets (title, description, -paper content, license, owning project). - -```bash -# List datasets for one or more projects (by numeric project ID) -gitlink-cli dataset +list --ids 5988 - -# View a repository's dataset and attachments -gitlink-cli dataset +view --owner Gitlink --repo forgeplus - -# Create / update a repository's dataset (preview first with --dry-run) -gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run -gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" - -# Delete a dataset attachment (destructive: preview, then confirm with --yes) -gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run -gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes -``` - -> Note: `dataset +list` (platform dataset query) is verified on production -> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the -> published OpenAPI contract but are not yet deployed on production (they return -> 404 there); they will work once the platform enables them. - ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: @@ -765,13 +650,13 @@ git push gitlink The `skills/` directory contains Agent Skill files for AI-automated GitLink operations. -See [skills/README.md](./skills/README.md) for details. +See [skills/README.md](skills/README.md) for details. | Skill | Description | |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | -| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) | +| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | @@ -835,7 +720,7 @@ gitlink-cli/ ## Documentation -- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation +- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation - [Design Document](doc/design.md) — Architecture design and development plan ## FAQ @@ -900,7 +785,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent ### Q: Where can I find the full API reference? -See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md). +See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md). ## License 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 2de380c..691e63b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/attachment" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -14,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" @@ -33,47 +33,47 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "attachment": attachment.Shortcuts(), - "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), - "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(), - "webhook": webhook.Shortcuts(tr), - "health": health.Shortcuts(tr), - "workflow": workflow.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "label": label.Shortcuts(), + "license": license.Shortcuts(), + "member": member.Shortcuts(), + "message": message.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pr": pr.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(), + "webhook": webhook.Shortcuts(tr), + "health": health.Shortcuts(tr), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "attachment": "Attachment operations", - "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"), - "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", - "webhook": tr.T("cmd.webhook.short"), - "health": "Project health data collection", - "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": "Message center operations", + "milestone": "Milestone operations", + "pipeline": "Pipeline operations", + "pr": tr.T("cmd.pr.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", + "webhook": tr.T("cmd.webhook.short"), + "health": "Project health data collection", + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 0f15d36..65600c0 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "attachment", "repo", "issue", "label", "license", "pr", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "health", + "health", "message", } groupSet := map[string]bool{}