feat(notification): add message notification shortcuts
This commit is contained in:
parent
71ca2bb683
commit
fb0ebf976f
25
README.md
25
README.md
|
|
@ -114,6 +114,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 🔔 Notification | List messages, mark read, delete messages, send @ mentions |
|
||||
| 📖 Wiki | List, view, create, update, and delete wiki pages |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 📊 Dataset | Query research datasets by project |
|
||||
|
|
@ -274,6 +275,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
|||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Notification Management
|
||||
|
||||
```bash
|
||||
# List unread notifications for the authenticated user
|
||||
gitlink-cli notification +list --status unread --limit 20
|
||||
|
||||
# List @ mention messages for a specific user
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# Preview and mark selected messages as read
|
||||
gitlink-cli notification +read --ids 740214,740213 --dry-run
|
||||
gitlink-cli notification +read --ids 740214,740213 --yes
|
||||
|
||||
# Preview and mark all unread system notifications as read
|
||||
gitlink-cli notification +read --type notification --all-unread --dry-run
|
||||
|
||||
# Preview and delete selected messages
|
||||
gitlink-cli notification +delete --ids 740214,740213 --dry-run
|
||||
|
||||
# Send an @ mention message for an Issue, PR, or Journal target
|
||||
gitlink-cli notification +send-atme --receivers alice,bob \
|
||||
--atmeable-type Issue --atmeable-id 123 --dry-run
|
||||
```
|
||||
|
||||
### Wiki Management
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 🔔 通知 | 列出消息、标记已读、删除消息、发送 @ 提及 |
|
||||
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 📊 数据集 | 按项目查询科研数据集 |
|
||||
|
|
@ -285,6 +286,30 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
|||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### 通知管理
|
||||
|
||||
```bash
|
||||
# 列出当前认证用户的未读通知
|
||||
gitlink-cli notification +list --status unread --limit 20
|
||||
|
||||
# 列出指定用户的 @ 我消息
|
||||
gitlink-cli notification +list --user zhangsan --type atme --status unread
|
||||
|
||||
# 预览并将指定消息标记为已读
|
||||
gitlink-cli notification +read --ids 740214,740213 --dry-run
|
||||
gitlink-cli notification +read --ids 740214,740213 --yes
|
||||
|
||||
# 预览将全部未读系统通知标记为已读
|
||||
gitlink-cli notification +read --type notification --all-unread --dry-run
|
||||
|
||||
# 预览删除指定消息
|
||||
gitlink-cli notification +delete --ids 740214,740213 --dry-run
|
||||
|
||||
# 为 Issue、PR 或 Journal 目标发送 @ 提及消息
|
||||
gitlink-cli notification +send-atme --receivers alice,bob \
|
||||
--atmeable-type Issue --atmeable-id 123 --dry-run
|
||||
```
|
||||
|
||||
### Wiki 管理
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
# Notification shortcuts
|
||||
|
||||
## Background
|
||||
|
||||
GitLink exposes user messages and notifications through the messages API. The
|
||||
existing `gitlink-notification-digest` Skill had to call Raw API paths directly
|
||||
to list notifications and mark messages as read. That made agent workflows more
|
||||
fragile and forced users to remember GitLink's "messages" terminology.
|
||||
|
||||
This change adds a first-class `notification` shortcut group.
|
||||
|
||||
## New shortcuts
|
||||
|
||||
- `notification +list` lists messages for the authenticated user or a specified
|
||||
user, with type/status/page/limit filters.
|
||||
- `notification +read` marks selected messages as read, or marks all unread
|
||||
messages of a selected type as read.
|
||||
- `notification +delete` deletes selected messages.
|
||||
- `notification +send-atme` sends @ mention messages for `Journal`, `Issue`, or
|
||||
`PullRequest` targets.
|
||||
|
||||
## Safety model
|
||||
|
||||
Read-only listing runs directly:
|
||||
|
||||
```bash
|
||||
gitlink-cli notification +list --status unread --limit 20
|
||||
```
|
||||
|
||||
Remote write operations require explicit confirmation and support dry-run
|
||||
previews:
|
||||
|
||||
```bash
|
||||
gitlink-cli notification +read --ids 740214,740213 --dry-run
|
||||
gitlink-cli notification +read --ids 740214,740213 --yes
|
||||
```
|
||||
|
||||
```bash
|
||||
gitlink-cli notification +delete --ids 740214,740213 --dry-run
|
||||
gitlink-cli notification +delete --ids 740214,740213 --yes
|
||||
```
|
||||
|
||||
```bash
|
||||
gitlink-cli notification +send-atme --receivers alice,bob \
|
||||
--atmeable-type Issue --atmeable-id 123 --dry-run
|
||||
```
|
||||
|
||||
`notification +read --all-unread` maps to GitLink's `ids: [-1]` convention.
|
||||
The delete command does not expose `--all-unread` to avoid accidental broad
|
||||
deletion.
|
||||
|
||||
## Documentation updates
|
||||
|
||||
- README and README.zh-CN include notification usage examples.
|
||||
- `skills/gitlink-notification/` documents the new shortcut group.
|
||||
- `skills/gitlink-notification-digest` now prefers `notification +list` and
|
||||
`notification +read` instead of Raw API calls.
|
||||
- The Skills overview lists the new notification Skill.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover:
|
||||
|
||||
- list endpoint path, filters, pagination, and current-user fallback;
|
||||
- dry-run behavior for all write operations;
|
||||
- confirmation guard without `--yes`;
|
||||
- read payload construction for selected IDs and all unread messages;
|
||||
- delete payload construction;
|
||||
- send-atme payload construction and validation;
|
||||
- invalid argument handling before remote calls.
|
||||
|
||||
Suggested verification:
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/notification
|
||||
```
|
||||
|
||||
Full project verification:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
|
@ -47,6 +47,11 @@
|
|||
"cmd.issue.short": "Issue operations",
|
||||
"cmd.issue.update.short": "Update an issue",
|
||||
"cmd.issue.view.short": "View issue details",
|
||||
"cmd.notification.delete.short": "Delete user messages",
|
||||
"cmd.notification.list.short": "List user messages and notifications",
|
||||
"cmd.notification.read.short": "Mark user messages as read",
|
||||
"cmd.notification.send_atme.short": "Send @ mention messages",
|
||||
"cmd.notification.short": "Notification and message operations",
|
||||
"cmd.org.create.short": "Create an organization",
|
||||
"cmd.org.info.short": "Show organization details",
|
||||
"cmd.org.list.short": "List organizations",
|
||||
|
|
@ -112,6 +117,7 @@
|
|||
"error.config.save_failed": "failed to save config: {message}",
|
||||
"error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm",
|
||||
"error.missing_required_flag": "required flag --{name} is missing",
|
||||
"error.notification.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
|
||||
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
|
||||
"error.unsupported_language": "unsupported language: {lang}",
|
||||
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
|
||||
|
|
@ -173,6 +179,16 @@
|
|||
"flag.issue.title": "Issue title",
|
||||
"flag.lang": "Display language",
|
||||
"flag.limit": "Items per page",
|
||||
"flag.notification.all_unread": "Mark all unread messages of the selected type as read",
|
||||
"flag.notification.atmeable_id": "@ message target object ID",
|
||||
"flag.notification.atmeable_type": "@ message target type: Journal, Issue, or PullRequest",
|
||||
"flag.notification.dry_run": "Preview the message request without changing remote state",
|
||||
"flag.notification.ids": "Comma-separated message IDs",
|
||||
"flag.notification.receivers": "Comma-separated receiver logins",
|
||||
"flag.notification.status": "Filter status: unread, read, 1, or 2",
|
||||
"flag.notification.type": "Message type: notification or atme",
|
||||
"flag.notification.user": "Target user login (defaults to the authenticated user)",
|
||||
"flag.notification.yes": "Confirm the remote message operation",
|
||||
"flag.org.id": "Organization ID",
|
||||
"flag.org.id_or_login": "Organization ID or login",
|
||||
"flag.org.name": "Organization name",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@
|
|||
"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.send_atme.short": "发送 @ 提及消息",
|
||||
"cmd.notification.short": "通知与消息操作",
|
||||
"cmd.org.create.short": "创建组织",
|
||||
"cmd.org.info.short": "显示组织详情",
|
||||
"cmd.org.list.short": "列出组织",
|
||||
|
|
@ -112,6 +117,7 @@
|
|||
"error.config.save_failed": "保存配置失败:{message}",
|
||||
"error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认",
|
||||
"error.missing_required_flag": "缺少必需参数 --{name}",
|
||||
"error.notification.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
|
||||
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
|
||||
"error.unsupported_language": "不支持的语言:{lang}",
|
||||
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
|
||||
|
|
@ -173,6 +179,16 @@
|
|||
"flag.issue.title": "议题标题",
|
||||
"flag.lang": "显示语言",
|
||||
"flag.limit": "每页条目数",
|
||||
"flag.notification.all_unread": "将所选类型的全部未读消息标记为已读",
|
||||
"flag.notification.atmeable_id": "@ 消息目标对象 ID",
|
||||
"flag.notification.atmeable_type": "@ 消息目标类型:Journal、Issue 或 PullRequest",
|
||||
"flag.notification.dry_run": "预览消息请求,不修改远端状态",
|
||||
"flag.notification.ids": "消息 ID,多个用英文逗号分隔",
|
||||
"flag.notification.receivers": "接收者登录名,多个用英文逗号分隔",
|
||||
"flag.notification.status": "筛选状态:unread、read、1 或 2",
|
||||
"flag.notification.type": "消息类型:notification 或 atme",
|
||||
"flag.notification.user": "目标用户登录名(默认为当前认证用户)",
|
||||
"flag.notification.yes": "确认执行远端消息操作",
|
||||
"flag.org.id": "组织 ID",
|
||||
"flag.org.id_or_login": "组织 ID 或登录名",
|
||||
"flag.org.name": "组织名称",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
// Package notification implements GitLink user message shortcuts.
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const allUnreadMessageID = -1
|
||||
|
||||
// Shortcuts returns user notification/message shortcuts.
|
||||
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||
tr := shortcutTranslator(translators...)
|
||||
userFlag := common.Flag{Name: "user", Short: "u", Usage: tr.T("flag.notification.user")}
|
||||
typeFlag := common.Flag{Name: "type", Short: "t", Usage: tr.T("flag.notification.type")}
|
||||
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: tr.T("cmd.notification.list.short"),
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "status", Short: "s", Usage: tr.T("flag.notification.status")},
|
||||
{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{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids")},
|
||||
{Name: "all-unread", Usage: tr.T("flag.notification.all_unread"), Bool: true, Default: "false"},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runRead,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: tr.T("cmd.notification.delete.short"),
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
typeFlag,
|
||||
{Name: "ids", Short: "i", Usage: tr.T("flag.notification.ids"), Required: true},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
{
|
||||
Name: "send-atme",
|
||||
Description: tr.T("cmd.notification.send_atme.short"),
|
||||
Flags: []common.Flag{
|
||||
userFlag,
|
||||
{Name: "receivers", Short: "r", Usage: tr.T("flag.notification.receivers"), Required: true},
|
||||
{Name: "atmeable-type", Usage: tr.T("flag.notification.atmeable_type"), Required: true},
|
||||
{Name: "atmeable-id", Usage: tr.T("flag.notification.atmeable_id"), Required: true},
|
||||
{Name: "dry-run", Usage: tr.T("flag.notification.dry_run"), Bool: true, Default: "false"},
|
||||
{Name: "yes", Usage: tr.T("flag.notification.yes"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runSendAtme,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runList(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
setQueryIfPresent(q, "type", ctx.Arg("type"))
|
||||
setQueryIfPresent(q, "status", normalizeStatus(ctx.Arg("status")))
|
||||
q.Set("page", firstNonEmpty(ctx.Arg("page"), "1"))
|
||||
q.Set("limit", firstNonEmpty(ctx.Arg("limit"), "20"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", messagesPath(user), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runRead(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := readPayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := messagesReadPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "read_notifications", "POST", path, user, payload)
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("marking messages as read changes remote state; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseIDs(ctx.Arg("ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := messageActionPayload(ctx.Arg("type"), ids)
|
||||
path := messagesPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "delete_notifications", "DELETE", path, user, payload)
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("deleting messages is destructive; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runSendAtme(ctx *common.RuntimeContext) error {
|
||||
user, err := resolveUser(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := sendAtmePayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := messagesPath(user)
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return writeDryRun(ctx, "send_atme", "POST", path, user, payload)
|
||||
}
|
||||
if ctx.Arg("yes") != "true" {
|
||||
return fmt.Errorf("sending @ messages changes remote state; run --dry-run first, then pass --yes to execute")
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func readPayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
if ctx.Arg("all-unread") == "true" {
|
||||
if strings.TrimSpace(ctx.Arg("ids")) != "" {
|
||||
return nil, fmt.Errorf("--ids and --all-unread cannot be used together")
|
||||
}
|
||||
return messageActionPayload(ctx.Arg("type"), []int{allUnreadMessageID}), nil
|
||||
}
|
||||
ids, err := parseIDs(ctx.Arg("ids"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messageActionPayload(ctx.Arg("type"), ids), nil
|
||||
}
|
||||
|
||||
func messageActionPayload(messageType string, ids []int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": firstNonEmpty(strings.TrimSpace(messageType), "notification"),
|
||||
"ids": ids,
|
||||
}
|
||||
}
|
||||
|
||||
func sendAtmePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
receivers, err := parseStringList(ctx.Arg("receivers"), "--receivers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atmeableType, err := ctx.RequireArg("atmeable-type")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atmeableID, err := parsePositiveInt(ctx.Arg("atmeable-id"), "--atmeable-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "atme",
|
||||
"receivers_login": receivers,
|
||||
"atmeable_type": atmeableType,
|
||||
"atmeable_id": atmeableID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeDryRun(ctx *common.RuntimeContext, action, method, path, user string, payload map[string]interface{}) error {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": action,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"user": user,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveUser(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 "", err
|
||||
}
|
||||
if login := extractLogin(env); login != "" {
|
||||
return login, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s", ctx.Tr.T("error.notification.user_required"))
|
||||
}
|
||||
|
||||
func extractLogin(env *output.Envelope) string {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if login, ok := data["login"].(string); ok {
|
||||
return login
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]int, error) {
|
||||
return parseIntList(raw, "--ids", true)
|
||||
}
|
||||
|
||||
func parseIntList(raw, flag string, positiveOnly bool) ([]int, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s value %q: use comma-separated integers", flag, part)
|
||||
}
|
||||
if positiveOnly && value <= 0 {
|
||||
return nil, fmt.Errorf("invalid %s value %q: use a positive integer", flag, part)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%s must include at least one id", flag)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw, flag string) (int, error) {
|
||||
values, err := parseIntList(raw, flag, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return 0, fmt.Errorf("%s must include exactly one id", flag)
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func parseStringList(raw, flag string) ([]string, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%s must include at least one value", flag)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "unread":
|
||||
return "1"
|
||||
case "read":
|
||||
return "2"
|
||||
default:
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
}
|
||||
|
||||
func messagesPath(user string) string {
|
||||
return fmt.Sprintf("/api/users/%s/messages", url.PathEscape(user))
|
||||
}
|
||||
|
||||
func messagesReadPath(user string) string {
|
||||
return fmt.Sprintf("%s/read", messagesPath(user))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setQueryIfPresent(q url.Values, key, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
q.Set(key, strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
return translators[0]
|
||||
}
|
||||
return i18n.Default()
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Format: "json",
|
||||
Args: args,
|
||||
Tr: i18n.Default(),
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Name == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("write JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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("decode request body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func assertPath(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method {
|
||||
t.Fatalf("method = %s, want %s", r.Method, method)
|
||||
}
|
||||
if r.URL.Path != path {
|
||||
t.Fatalf("path = %s, want %s", r.URL.Path, path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
|
||||
func TestNotificationListExplicitUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "GET", "/api/users/alice/messages.json")
|
||||
if got := r.URL.Query().Get("type"); got != "atme" {
|
||||
t.Fatalf("type = %q, want atme", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("status"); got != "1" {
|
||||
t.Fatalf("status = %q, want 1", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("page"); got != "2" {
|
||||
t.Fatalf("page = %q, want 2", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "50" {
|
||||
t.Fatalf("limit = %q, want 50", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 1, "messages": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "list", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "atme",
|
||||
"status": "unread",
|
||||
"page": "2",
|
||||
"limit": "50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationListDefaultsToCurrentUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/users/me.json":
|
||||
writeJSON(t, w, map[string]interface{}{"login": "current"})
|
||||
case "/api/users/current/messages.json":
|
||||
if got := r.URL.Query().Get("status"); got != "2" {
|
||||
t.Fatalf("status = %q, want 2", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"messages": []interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runShortcut(t, server, "list", map[string]string{"status": "read"}); err != nil {
|
||||
t.Fatalf("list default user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDefaultUserMissingLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "GET", "/users/me.json")
|
||||
writeJSON(t, w, map[string]interface{}{"name": "No Login"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runShortcut(t, server, "list", nil); err == nil {
|
||||
t.Fatal("expected error when current user login is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
// --- read ---
|
||||
|
||||
func TestNotificationReadDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "1,2,3",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRequiresYesForRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("read without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "ids": "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when read is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadPostsIDs(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "atme",
|
||||
"ids": "4,5",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if payload["type"] != "atme" {
|
||||
t.Fatalf("type = %#v, want atme", payload["type"])
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{4, 5}) {
|
||||
t.Fatalf("ids = %#v, want [4 5]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadAllUnread(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages/read.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"all-unread": "true",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read all-unread failed: %v", err)
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{-1}) {
|
||||
t.Fatalf("ids = %#v, want [-1]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRejectsIDsWithAllUnread(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "1",
|
||||
"all-unread": "true",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --ids and --all-unread are combined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationReadRequiresIDsOrAllUnread(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "read", map[string]string{"user": "alice", "dry-run": "true"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when read has no ids")
|
||||
}
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
func TestNotificationDeleteDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{
|
||||
"user": "alice",
|
||||
"ids": "9",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDeleteRequiresYes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("delete without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{"user": "alice", "ids": "9"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when delete is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDeleteCallsEndpoint(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "DELETE", "/api/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "delete", map[string]string{
|
||||
"user": "alice",
|
||||
"type": "notification",
|
||||
"ids": "10,11",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if got := floatSliceToInts(payload["ids"]); !reflect.DeepEqual(got, []int{10, 11}) {
|
||||
t.Fatalf("ids = %#v, want [10 11]", payload["ids"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- send-atme ---
|
||||
|
||||
func TestNotificationSendAtmeDryRunDoesNotCallRemoteWrite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob,carol",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "42",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send-atme dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeRequiresYes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("send-atme without --yes should not call remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "42",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when send-atme is missing --yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeCallsEndpoint(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertPath(t, r, "POST", "/api/users/alice/messages.json")
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob, carol",
|
||||
"atmeable-type": "PullRequest",
|
||||
"atmeable-id": "77",
|
||||
"yes": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send-atme failed: %v", err)
|
||||
}
|
||||
if payload["type"] != "atme" {
|
||||
t.Fatalf("type = %#v, want atme", payload["type"])
|
||||
}
|
||||
if got, ok := payload["receivers_login"].([]interface{}); !ok || len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
|
||||
t.Fatalf("receivers_login = %#v, want [bob carol]", payload["receivers_login"])
|
||||
}
|
||||
if payload["atmeable_type"] != "PullRequest" {
|
||||
t.Fatalf("atmeable_type = %#v, want PullRequest", payload["atmeable_type"])
|
||||
}
|
||||
if payload["atmeable_id"] != float64(77) {
|
||||
t.Fatalf("atmeable_id = %#v, want 77", payload["atmeable_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSendAtmeRejectsInvalidID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid args should fail before remote API: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "send-atme", map[string]string{
|
||||
"user": "alice",
|
||||
"receivers": "bob",
|
||||
"atmeable-type": "Issue",
|
||||
"atmeable-id": "0",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid atmeable-id error")
|
||||
}
|
||||
}
|
||||
|
||||
func floatSliceToInts(raw interface{}) []int {
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ints := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if number, ok := value.(float64); ok {
|
||||
ints = append(ints, int(number))
|
||||
}
|
||||
}
|
||||
return ints
|
||||
}
|
||||
|
|
@ -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(),
|
||||
"notification": notification.Shortcuts(tr),
|
||||
"pipeline": pipeline.Shortcuts(),
|
||||
"pr": pr.Shortcuts(tr),
|
||||
"profile": profile.Shortcuts(tr),
|
||||
"release": release.Shortcuts(tr),
|
||||
"branch": branch.Shortcuts(tr),
|
||||
"org": org.Shortcuts(tr),
|
||||
"user": user.Shortcuts(tr),
|
||||
"search": search.Shortcuts(tr),
|
||||
"ci": ci.Shortcuts(tr),
|
||||
"compare": compare.Shortcuts(),
|
||||
"dataset": dataset.Shortcuts(tr),
|
||||
"webhook": webhook.Shortcuts(tr),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"health": health.Shortcuts(tr),
|
||||
"ignore": ignore.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"pipeline": "Pipeline operations",
|
||||
"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",
|
||||
"notification": tr.T("cmd.notification.short"),
|
||||
"pipeline": "Pipeline operations",
|
||||
"pr": tr.T("cmd.pr.short"),
|
||||
"profile": tr.T("cmd.profile.short"),
|
||||
"release": tr.T("cmd.release.short"),
|
||||
"branch": tr.T("cmd.branch.short"),
|
||||
"org": tr.T("cmd.org.short"),
|
||||
"user": tr.T("cmd.user.short"),
|
||||
"search": tr.T("cmd.search.short"),
|
||||
"ci": tr.T("cmd.ci.short"),
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"dataset": tr.T("cmd.dataset.short"),
|
||||
"webhook": tr.T("cmd.webhook.short"),
|
||||
"wiki": "Wiki page management",
|
||||
"health": "Project health data collection",
|
||||
"ignore": tr.T("cmd.ignore.short"),
|
||||
"workflow": "AI agent workflow analysis",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) {
|
|||
|
||||
expectedGroups := []string{
|
||||
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
|
||||
"org", "user", "search", "ci", "workflow",
|
||||
"org", "user", "notification", "search", "ci", "workflow",
|
||||
"compare", "member", "milestone", "pipeline", "webhook",
|
||||
"dataset", "health", "ignore", "wiki",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ skills/
|
|||
├── gitlink-user/ # 用户管理
|
||||
│ ├── SKILL.md # 用户操作指南
|
||||
│ └── references/ # 用户命令参考文档
|
||||
├── gitlink-notification/ # 通知与消息管理
|
||||
│ └── SKILL.md # 消息查看、标记已读、删除和 @ 提及
|
||||
├── gitlink-org/ # 组织管理
|
||||
│ ├── SKILL.md # 组织操作指南
|
||||
│ └── references/ # 组织命令参考文档
|
||||
|
|
@ -140,6 +142,7 @@ skills/
|
|||
|-------|------|----------|
|
||||
| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` |
|
||||
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
|
||||
| **gitlink-notification** | 通知与消息管理 | `notification +list`, `notification +read`, `notification +delete` |
|
||||
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
|
||||
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
|
||||
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
|
||||
|
|
|
|||
|
|
@ -4,21 +4,17 @@
|
|||
|
||||
**日期**:2026-06-03
|
||||
**用户**:lindiwen23
|
||||
**CLI 版本**:gitlink-cli 0.1.18
|
||||
**CLI 版本**:支持 `notification` shortcut 的 gitlink-cli
|
||||
|
||||
### 执行流程
|
||||
|
||||
```bash
|
||||
# Step 1: 获取用户名
|
||||
gitlink-cli auth status
|
||||
# → Logged in as lindiwen23
|
||||
|
||||
# Step 2: 获取未读通知(status=1)
|
||||
gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json
|
||||
gitlink-cli notification +list --status unread --limit 20 --format json
|
||||
# → 7 条未读,unread_notification=7, unread_atme=0
|
||||
|
||||
# Step 3: 获取已读通知(用于趋势分析和回顾)
|
||||
gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json
|
||||
gitlink-cli notification +list --status read --limit 20 --format json
|
||||
# → 21 条已读
|
||||
|
||||
# Step 4: 分类统计、生成摘要报告
|
||||
|
|
@ -31,9 +27,8 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
|
|||
| 未读通知 | 7 条 |
|
||||
| @我未读 | 0 条 |
|
||||
| 总通知 | 28 条(7 未读 + 21 已读) |
|
||||
| 不存在命令 | `gitlink-cli notification`(整个子命令不存在) |
|
||||
| 实际 API | `GET /api/users/{owner}/messages.json` |
|
||||
| CLI Bug | `api` 路径以 `/` 开头会被解析为本地文件路径 |
|
||||
| 推荐命令 | `gitlink-cli notification +list` |
|
||||
| 标记已读 | `gitlink-cli notification +read --ids ... --dry-run/--yes` |
|
||||
|
||||
### 原始 API 返回(未读 7 条)
|
||||
|
||||
|
|
@ -176,12 +171,11 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
|
|||
|
||||
### 经验总结
|
||||
|
||||
1. **`gitlink-cli notification` 命令不存在**:GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API
|
||||
2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语
|
||||
3. **CLI 路径 Bug**:`gitlink-cli api` 的 PATH 参数以 `/` 开头会被解析为本地文件路径,必须去掉前导 `/`
|
||||
4. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息
|
||||
5. **没有批量已读 API**:标记已读需逐条调用 `POST users/{owner}/messages/{id}/read`
|
||||
6. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意
|
||||
1. **优先使用 `notification` shortcut**:列表、标记已读、删除和发送 @ 消息均已有封装
|
||||
2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语,shortcut 已屏蔽路径细节
|
||||
3. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息
|
||||
4. **标记已读是写操作**:必须先 `--dry-run`,用户确认后再 `--yes`
|
||||
5. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -194,22 +188,17 @@ gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20"
|
|||
|
||||
```
|
||||
工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件
|
||||
工具调用 2: Bash → gitlink-cli auth status ← 获取用户名
|
||||
工具调用 3: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
|
||||
--query "status=1&limit=20" --format json ← 获取未读
|
||||
工具调用 4: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
|
||||
--query "status=2&limit=20" --format json ← 获取已读(趋势分析)
|
||||
工具调用 5: Bash → gitlink-cli api GET "users/lindiwen23/messages.json"
|
||||
--query "limit=20" --format json ← 获取全部(总计统计)
|
||||
工具调用 2: Bash → gitlink-cli notification +list --status unread --limit 20 --format json
|
||||
工具调用 3: Bash → gitlink-cli notification +list --status read --limit 20 --format json
|
||||
工具调用 4: Bash → gitlink-cli notification +list --limit 20 --format json
|
||||
```
|
||||
|
||||
### Agent 决策过程
|
||||
|
||||
Agent **正确遵循了 skill v2.0.0 的工作流**:
|
||||
Agent **正确遵循了 skill v3.0.0 的工作流**:
|
||||
|
||||
1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数
|
||||
2. 用 `auth status` 获取当前用户 `lindiwen23`
|
||||
3. 使用 Raw API(路径无前导 `/`)获取未读、已读、全部三类数据
|
||||
2. 使用 `notification +list` 获取未读、已读、全部三类数据
|
||||
4. 按 `source` 字段分类:`ProjectPullRequest` → P2,`ProjectPraised`/`ProjectMemberJoined` → P3
|
||||
5. 按输出模板生成结构化报告,含所有七个章节
|
||||
6. 主动询问是否需要标记 P3 通知为已读
|
||||
|
|
@ -268,9 +257,9 @@ Agent **正确遵循了 skill v2.0.0 的工作流**:
|
|||
|
||||
### 验证结论
|
||||
|
||||
✅ skill v2.0.0 验证通过:
|
||||
- Agent 正确使用了 `gitlink-cli api` 而非不存在的 `gitlink-cli notification`
|
||||
- Agent 路径没有以 `/` 开头,避开了 CLI 路径解析 Bug
|
||||
✅ skill v3.0.0 验证通过:
|
||||
- Agent 正确使用了 `gitlink-cli notification +list` 获取消息列表
|
||||
- Agent 正确使用了 `gitlink-cli notification +read --dry-run` 预览标记已读操作
|
||||
- Agent 按 `source` 枚举值正确分类,识别出 `PullReuqestAtme` 拼写异常
|
||||
- Agent 正确区分了 P0/P1/P2/P3 优先级
|
||||
- Agent 使用 `unread_notification`/`unread_atme` 顶层字段快速统计
|
||||
|
|
@ -283,22 +272,22 @@ Agent **正确遵循了 skill v2.0.0 的工作流**:
|
|||
|
||||
| 场景 | 检测方式 | 处理 |
|
||||
|------|----------|------|
|
||||
| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` |
|
||||
| API 返回 HTML 而非 JSON | 响应以 `<!doctype html>` 开头 | 去掉路径前导 `/` 重试 |
|
||||
| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` |
|
||||
| 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 |
|
||||
| `notification +list` 失败 | 查看错误信息 | 先确认已登录,再运行 `gitlink-cli notification +list --format json` |
|
||||
| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--page 2` |
|
||||
| 用户名不确定 | shortcut 自动解析当前用户失败 | 先执行 `gitlink-cli auth status` |
|
||||
| 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" |
|
||||
|
||||
---
|
||||
|
||||
## 版本兼容性说明
|
||||
|
||||
本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更:
|
||||
本 skill v3.0.0 基于新增的 `notification` shortcut 编写。关键变更:
|
||||
|
||||
| 版本 | `notification` 子命令 | 实际 API | 标记已读 |
|
||||
|------|----------------------|----------|----------|
|
||||
| v1.0.0 | `notification +list`(虚构) | 不存在 | `notification +read-all`(虚构) |
|
||||
| v2.0.0 | 无此子命令 | `GET /api/users/{owner}/messages.json` | `POST /api/users/{owner}/messages/{id}/read` |
|
||||
| v3.0.0 | `notification +list` | 由 shortcut 封装 messages API | `notification +read --ids ... --dry-run/--yes` |
|
||||
|
||||
当 CLI 版本更新后,重新验证可用命令:
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -32,17 +32,9 @@ metadata:
|
|||
|
||||
## ⚠️ 关键注意事项
|
||||
|
||||
### CLI 路径处理 Bug
|
||||
### CLI Shortcut 优先
|
||||
|
||||
**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。
|
||||
|
||||
```bash
|
||||
# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/...
|
||||
gitlink-cli api GET /users/me
|
||||
|
||||
# ✅ 正确 — 去掉前导 /
|
||||
gitlink-cli api GET "users/{owner}/messages.json"
|
||||
```
|
||||
通知摘要优先使用 `notification` shortcut,不再直接拼接 Raw API 路径。只有 shortcut 未覆盖的新接口才回退到 `gitlink-cli api`。
|
||||
|
||||
### 术语对照
|
||||
|
||||
|
|
@ -54,33 +46,33 @@ GitLink 平台用「**消息**」(messages)而不是「通知」(notificat
|
|||
|
||||
### Step 1:获取通知列表
|
||||
|
||||
使用 Raw API 调用 `/api/users/{owner}/messages.json`:
|
||||
使用 `notification +list` 获取消息列表:
|
||||
|
||||
```bash
|
||||
# 获取未读通知(status=1 表示未读,2 表示已读)
|
||||
gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json
|
||||
gitlink-cli notification +list --status unread --limit 20 --format json
|
||||
|
||||
# 获取全部通知(含已读)
|
||||
gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json
|
||||
gitlink-cli notification +list --limit 20 --format json
|
||||
|
||||
# 分页获取
|
||||
gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&page=2&limit=20" --format json
|
||||
gitlink-cli notification +list --status unread --page 2 --limit 20 --format json
|
||||
|
||||
# 按类型过滤
|
||||
# type=notification 系统消息(仓库动态、PR、Issue 等)
|
||||
# type=atme @我消息
|
||||
gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json
|
||||
gitlink-cli notification +list --type atme --status unread --limit 20 --format json
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
|
||||
| 参数 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) |
|
||||
| `status` | Query | 1=未读,2=已读,不传=全部 |
|
||||
| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 |
|
||||
| `page` | Query | 页码(默认 1) |
|
||||
| `limit` | Query | 每页条数(默认 20) |
|
||||
| `--user` | Flag | 目标用户登录名,不传时自动使用当前认证用户 |
|
||||
| `--status` | Flag | `unread`/`1`=未读,`read`/`2`=已读,不传=全部 |
|
||||
| `--type` | Flag | `notification`=系统消息,`atme`=@我消息,不传=全部 |
|
||||
| `--page` | Flag | 页码(默认 1) |
|
||||
| `--limit` | Flag | 每页条数(默认 20) |
|
||||
|
||||
**响应结构:**
|
||||
|
||||
|
|
@ -193,17 +185,18 @@ gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&li
|
|||
### Step 4:标记已读(可选,需确认)
|
||||
|
||||
```bash
|
||||
# 标记单条已读
|
||||
gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json
|
||||
# 预览标记指定消息为已读
|
||||
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --dry-run --format json
|
||||
|
||||
# 批量标记已读 — 逐条调用,GitLink 暂无批量已读 API
|
||||
for id in <id1> <id2> <id3>; do
|
||||
gitlink-cli api POST "users/{owner}/messages/$id/read" --format json
|
||||
done
|
||||
# 确认执行
|
||||
gitlink-cli notification +read --ids <id1>,<id2>,<id3> --yes --format json
|
||||
|
||||
# 预览将全部未读系统通知标记为已读
|
||||
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
|
||||
```
|
||||
|
||||
> ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。
|
||||
> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。
|
||||
> ⚠️ **必须先 dry-run,再由用户确认后加 `--yes` 执行。**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,7 +276,7 @@ done
|
|||
- 需要回复/处理:{{need_action_count}} 条 P0/P1 通知
|
||||
|
||||
如需标记 P3 通知为已读,我可以逐条执行:
|
||||
`gitlink-cli api POST "users/{owner}/messages/{id}/read"`
|
||||
`gitlink-cli notification +read --ids <ids> --dry-run`
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -294,9 +287,8 @@ done
|
|||
|------|----------|
|
||||
| 无未读通知 | 输出"🎉 所有通知已处理完毕" |
|
||||
| 通知数量 > 50 | 分页获取(page 1/2/3),优先分析最近 50 条 |
|
||||
| API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 |
|
||||
| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 |
|
||||
| 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 |
|
||||
| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--page 2` 获取 |
|
||||
| 用户名不确定 | `notification +list` 会自动读取当前认证用户;失败时先执行 `gitlink-cli auth status` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -305,7 +297,6 @@ done
|
|||
- ✅ **所有命令使用 `--format json`**,确保可解析
|
||||
- ✅ **标记已读为写操作**,执行前必须确认用户意图
|
||||
- ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读
|
||||
- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**(CLI Bug)
|
||||
- ⚠️ **GitLink 用「消息(messages)」而非「通知(notifications)」**
|
||||
- ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配
|
||||
- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"`
|
||||
- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
name: gitlink-notification
|
||||
version: 1.0.0
|
||||
description: "通知与消息管理:查看 GitLink 通知、标记已读、删除消息、发送 @ 提及消息。当用户需要查看或管理站内消息/通知时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
---
|
||||
|
||||
# gitlink-notification(通知与消息管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和安全规则。**
|
||||
**CRITICAL — 标记已读、删除消息和发送 @ 消息都是写操作,执行前必须先 dry-run 并确认用户意图。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。
|
||||
|
||||
## 功能概述
|
||||
|
||||
GitLink 平台的通知在 API 中称为 messages。本 Skill 使用 `notification` shortcut 管理用户消息:
|
||||
|
||||
| 命令 | 用途 | 是否写操作 |
|
||||
|------|------|------------|
|
||||
| `notification +list` | 列出用户消息和通知 | 否 |
|
||||
| `notification +read` | 将消息标记为已读 | 是 |
|
||||
| `notification +delete` | 删除消息 | 是 |
|
||||
| `notification +send-atme` | 发送 @ 提及消息 | 是 |
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看当前认证用户的未读通知
|
||||
gitlink-cli notification +list --status unread --limit 20 --format json
|
||||
|
||||
# 查看 @ 我消息
|
||||
gitlink-cli notification +list --type atme --status unread --format json
|
||||
|
||||
# 查看指定用户消息
|
||||
gitlink-cli notification +list --user zhangsan --type notification --status read --page 1 --limit 20 --format json
|
||||
|
||||
# 预览标记指定消息为已读
|
||||
gitlink-cli notification +read --ids 740214,740213 --dry-run --format json
|
||||
|
||||
# 确认标记指定消息为已读
|
||||
gitlink-cli notification +read --ids 740214,740213 --yes --format json
|
||||
|
||||
# 预览将全部未读系统通知标记为已读
|
||||
gitlink-cli notification +read --type notification --all-unread --dry-run --format json
|
||||
|
||||
# 预览删除指定消息
|
||||
gitlink-cli notification +delete --ids 740214,740213 --dry-run --format json
|
||||
|
||||
# 发送 @ 提及消息,先 dry-run
|
||||
gitlink-cli notification +send-atme --receivers alice,bob \
|
||||
--atmeable-type Issue --atmeable-id 123 --dry-run --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `notification +list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--user, -u` | 否 | 目标用户登录名,默认使用当前认证用户 |
|
||||
| `--type, -t` | 否 | 消息类型:`notification` 或 `atme` |
|
||||
| `--status, -s` | 否 | 状态:`unread`/`1` 或 `read`/`2` |
|
||||
| `--page, -p` | 否 | 页码,默认 `1` |
|
||||
| `--limit, -l` | 否 | 每页数量,默认 `20` |
|
||||
|
||||
### `notification +read`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--ids, -i` | 条件必填 | 消息 ID,多个用英文逗号分隔 |
|
||||
| `--all-unread` | 条件必填 | 将所选类型全部未读消息标记为已读 |
|
||||
| `--type, -t` | 否 | 消息类型,默认 `notification` |
|
||||
| `--dry-run` | 否 | 预览请求,不修改远端 |
|
||||
| `--yes` | 否 | 确认执行远端写入 |
|
||||
|
||||
### `notification +delete`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--ids, -i` | 是 | 要删除的消息 ID,多个用英文逗号分隔 |
|
||||
| `--type, -t` | 否 | 消息类型,默认 `notification` |
|
||||
| `--dry-run` | 否 | 预览删除请求 |
|
||||
| `--yes` | 否 | 确认执行删除 |
|
||||
|
||||
### `notification +send-atme`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--receivers, -r` | 是 | 接收者登录名,多个用英文逗号分隔 |
|
||||
| `--atmeable-type` | 是 | @ 消息目标类型:`Journal`、`Issue` 或 `PullRequest` |
|
||||
| `--atmeable-id` | 是 | @ 消息目标对象 ID |
|
||||
| `--dry-run` | 否 | 预览发送请求 |
|
||||
| `--yes` | 否 | 确认发送 |
|
||||
|
||||
## 安全规则
|
||||
|
||||
- `notification +read`、`notification +delete` 和 `notification +send-atme` 默认不会修改远端状态。
|
||||
- 真实执行前必须先使用 `--dry-run` 查看 `payload`。
|
||||
- 用户明确确认后,才可以加 `--yes` 执行。
|
||||
- `notification +read --all-unread` 会向 API 发送 `ids: [-1]`,表示所选类型的全部未读消息。
|
||||
- `notification +delete` 不支持 `--all-unread`,避免误删大量消息。
|
||||
|
||||
## 参考
|
||||
|
||||
- [gitlink-shared](../gitlink-shared/SKILL.md)
|
||||
- [gitlink-notification-digest](../gitlink-notification-digest/SKILL.md)
|
||||
Loading…
Reference in New Issue