feat: add notification shortcuts
This commit is contained in:
parent
71ca2bb683
commit
b0a1f0e566
21
README.md
21
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.) |
|
||||
|
|
|
|||
|
|
@ -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、看板、周报等) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "组织名称",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
Loading…
Reference in New Issue