feat(message-settings): 增强消息通知设置快捷命令交付质量

This commit is contained in:
Mengz 2026-06-22 11:13:08 +08:00
parent b0a1f0e566
commit cb14f00f3e
7 changed files with 1311 additions and 46 deletions

View File

@ -113,6 +113,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Message Settings | Inspect and update personal message delivery preferences |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔔 Notification | List, read, and delete user messages |

View File

@ -113,6 +113,7 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 🔔 消息通知设置 | 查看并更新个人消息通知投递偏好 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔔 通知 | 列出、已读、删除用户消息 |
| 🔍 搜索 | 搜索仓库、用户 |
@ -269,6 +270,29 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### 消息通知设置
```bash
# 列出可用的消息通知设置分组和键
gitlink-cli message-settings +catalog
# 查看当前用户生效中的消息通知设置
gitlink-cli message-settings +view
# 只看另一个用户的仓库管理类消息设置
gitlink-cli message-settings +view --login Mengz --group ManageProject
# 预览关闭指定设置键的站内通知,不发送请求
gitlink-cli message-settings +update \
--channel notification \
--state off \
--keys Normal::Permission,ManageProject::Issue \
--dry-run
# 将预设应用到所有已知设置
gitlink-cli message-settings +preset --name notification-only --all
```
### Webhook 管理
```bash

View File

@ -51,6 +51,11 @@
"cmd.notification.list.short": "List user messages",
"cmd.notification.read.short": "Mark messages as read",
"cmd.notification.short": "User message operations",
"cmd.message_settings.catalog.short": "List available message setting groups and keys",
"cmd.message_settings.preset.short": "Apply a preset to selected message settings",
"cmd.message_settings.short": "Message settings operations",
"cmd.message_settings.update.short": "Update selected message settings while preserving other values",
"cmd.message_settings.view.short": "Show effective message settings for a user",
"cmd.org.create.short": "Create an organization",
"cmd.org.info.short": "Show organization details",
"cmd.org.list.short": "List organizations",
@ -183,6 +188,13 @@
"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.message_settings.all": "Apply to all known setting keys",
"flag.message_settings.channel": "Channel to change: notification, email, or both",
"flag.message_settings.group": "Filter or select groups by short name, for example: Normal,ManageProject",
"flag.message_settings.keys": "Comma-separated setting keys, for example: Normal::Permission,ManageProject::Issue",
"flag.message_settings.login": "Target user login (defaults to current authenticated user)",
"flag.message_settings.preset_name": "Preset name: all-on, all-off, notification-only, email-only",
"flag.message_settings.state": "Desired state: on/off, true/false, enable/disable",
"flag.org.id": "Organization ID",
"flag.org.id_or_login": "Organization ID or login",
"flag.org.name": "Organization name",

View File

@ -51,6 +51,11 @@
"cmd.notification.list.short": "列出用户消息",
"cmd.notification.read.short": "标记消息为已读",
"cmd.notification.short": "用户消息操作",
"cmd.message_settings.catalog.short": "列出可用的消息通知设置分组和键",
"cmd.message_settings.preset.short": "将预设应用到选中的消息通知设置",
"cmd.message_settings.short": "消息通知设置操作",
"cmd.message_settings.update.short": "在保留其他值的前提下更新选中的消息通知设置",
"cmd.message_settings.view.short": "查看用户当前生效的消息通知设置",
"cmd.org.create.short": "创建组织",
"cmd.org.info.short": "显示组织详情",
"cmd.org.list.short": "列出组织",
@ -183,6 +188,13 @@
"flag.notification.type": "消息类型notification 或 atme",
"flag.notification.type_all": "消息类型notification、atme 或 all",
"flag.notification.user": "用户登录名,默认使用当前认证用户。",
"flag.message_settings.all": "应用到所有已知的设置键",
"flag.message_settings.channel": "要修改的通道notification、email 或 both",
"flag.message_settings.group": "按短分组名筛选或选中分组例如Normal,ManageProject",
"flag.message_settings.keys": "逗号分隔的设置键例如Normal::Permission,ManageProject::Issue",
"flag.message_settings.login": "目标用户登录名(默认:当前认证用户)",
"flag.message_settings.preset_name": "预设名称all-on、all-off、notification-only、email-only",
"flag.message_settings.state": "目标状态on/off、true/false、enable/disable",
"flag.org.id": "组织 ID",
"flag.org.id_or_login": "组织 ID 或登录名",
"flag.org.name": "组织名称",

View File

@ -0,0 +1,877 @@
package messagesetting
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const (
channelNotification = "notification"
channelEmail = "email"
channelBoth = "both"
)
type catalogResponse struct {
SettingTypes []catalogGroup `json:"setting_types"`
}
type catalogGroup struct {
Type string `json:"type"`
TypeName string `json:"type_name"`
Settings []catalogSettingRow `json:"settings"`
}
type catalogSettingRow struct {
Name string `json:"name"`
Key string `json:"key"`
NotificationDisabled bool `json:"notification_disabled"`
EmailDisabled bool `json:"email_disabled"`
}
type userSettingResponse struct {
User map[string]interface{} `json:"user"`
NotificationBody map[string]bool `json:"notification_body"`
EmailBody map[string]bool `json:"email_body"`
}
type currentUserResponse struct {
Login string `json:"login"`
}
type settingMeta struct {
FullKey string
ShortKey string
Group string
GroupName string
Name string
DefaultNotificationEnabled *bool
DefaultEmailEnabled *bool
}
type settingRegistry struct {
OrderedGroups []string
GroupNames map[string]string
GroupOrder map[string]int
Items map[string]settingMeta
}
type groupedSettingOutput struct {
Group string `json:"group"`
GroupName string `json:"group_name,omitempty"`
Settings []settingOutput `json:"settings"`
Summary channelSummaryOutput `json:"summary"`
}
type settingOutput struct {
Key string `json:"key"`
ShortKey string `json:"short_key"`
Name string `json:"name"`
NotificationEnabled bool `json:"notification_enabled"`
EmailEnabled bool `json:"email_enabled"`
DefaultNotificationEnabled *bool `json:"default_notification_enabled,omitempty"`
DefaultEmailEnabled *bool `json:"default_email_enabled,omitempty"`
}
type channelSummaryOutput struct {
Total int `json:"total"`
NotificationEnabled int `json:"notification_enabled"`
EmailEnabled int `json:"email_enabled"`
}
type settingChangeOutput struct {
Key string `json:"key"`
ShortKey string `json:"short_key"`
Name string `json:"name"`
Group string `json:"group"`
GroupName string `json:"group_name,omitempty"`
NotificationBefore bool `json:"notification_before"`
NotificationAfter bool `json:"notification_after"`
EmailBefore bool `json:"email_before"`
EmailAfter bool `json:"email_after"`
}
type presetSpec struct {
Notification bool
Email bool
}
var allowedPresetNames = map[string]presetSpec{
"all-on": {Notification: true, Email: true},
"all-off": {Notification: false, Email: false},
"notification-only": {Notification: true, Email: false},
"email-only": {Notification: false, Email: true},
}
// Shortcuts returns message settings shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "catalog",
Description: tr.T("cmd.message_settings.catalog.short"),
Flags: []common.Flag{
{Name: "group", Usage: tr.T("flag.message_settings.group")},
},
Run: runCatalog,
},
{
Name: "view",
Description: tr.T("cmd.message_settings.view.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")},
{Name: "group", Usage: tr.T("flag.message_settings.group")},
},
Run: runView,
},
{
Name: "update",
Description: tr.T("cmd.message_settings.update.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")},
{Name: "channel", Usage: tr.T("flag.message_settings.channel"), Required: true},
{Name: "state", Usage: tr.T("flag.message_settings.state"), Required: true},
{Name: "keys", Usage: tr.T("flag.message_settings.keys")},
{Name: "group", Usage: tr.T("flag.message_settings.group")},
{Name: "all", Usage: tr.T("flag.message_settings.all"), Bool: true, Default: "false"},
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
},
Run: runUpdate,
},
{
Name: "preset",
Description: tr.T("cmd.message_settings.preset.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: tr.T("flag.message_settings.login")},
{Name: "name", Usage: tr.T("flag.message_settings.preset_name"), Required: true},
{Name: "keys", Usage: tr.T("flag.message_settings.keys")},
{Name: "group", Usage: tr.T("flag.message_settings.group")},
{Name: "all", Usage: tr.T("flag.message_settings.all"), Bool: true, Default: "false"},
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
},
Run: runPreset,
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func runCatalog(ctx *common.RuntimeContext) error {
registry, err := loadRegistry(ctx)
if err != nil {
return err
}
groups, err := parseGroupFilter(ctx.Arg("group"), registry)
if err != nil {
return err
}
outputGroups := renderCatalogGroups(registry, groups)
return ctx.OutputData(map[string]interface{}{
"action": "catalog_message_settings",
"groups": outputGroups,
"summary": summarizeCatalogGroups(outputGroups),
})
}
func runView(ctx *common.RuntimeContext) error {
login, registry, current, err := loadUserSettingContext(ctx)
if err != nil {
return err
}
groups, err := parseGroupFilter(ctx.Arg("group"), registry)
if err != nil {
return err
}
outputGroups := renderUserGroups(registry, current, groups)
return ctx.OutputData(map[string]interface{}{
"action": "view_message_settings",
"login": login,
"user": current.User,
"groups": outputGroups,
"summary": summarizeUserGroups(outputGroups),
})
}
func runUpdate(ctx *common.RuntimeContext) error {
channel, err := parseChannel(ctx.Arg("channel"))
if err != nil {
return err
}
state, err := parseState(ctx.Arg("state"))
if err != nil {
return err
}
result, err := updateSettings(ctx, updateRequest{
Action: "update_message_settings",
Channel: channel,
Selector: selectorArgsFromContext(ctx),
DryRun: parseBoolArg(ctx.Arg("dry-run")),
Apply: func(notificationCurrent, emailCurrent bool) (bool, bool) {
nextNotification := notificationCurrent
nextEmail := emailCurrent
switch channel {
case channelNotification:
nextNotification = state
case channelEmail:
nextEmail = state
case channelBoth:
nextNotification = state
nextEmail = state
}
return nextNotification, nextEmail
},
Extra: map[string]interface{}{
"channel": channel,
"state": state,
},
})
if err != nil {
return err
}
return ctx.OutputData(result)
}
func runPreset(ctx *common.RuntimeContext) error {
name := strings.ToLower(strings.TrimSpace(ctx.Arg("name")))
preset, ok := allowedPresetNames[name]
if !ok {
return fmt.Errorf("invalid --name value %q", ctx.Arg("name"))
}
result, err := updateSettings(ctx, updateRequest{
Action: "preset_message_settings",
Selector: selectorArgsFromContext(ctx),
DryRun: parseBoolArg(ctx.Arg("dry-run")),
Apply: func(_, _ bool) (bool, bool) {
return preset.Notification, preset.Email
},
Extra: map[string]interface{}{
"preset": name,
},
})
if err != nil {
return err
}
return ctx.OutputData(result)
}
type selectorArgs struct {
Keys string
Group string
All bool
}
type updateRequest struct {
Action string
Channel string
Selector selectorArgs
DryRun bool
Apply func(notificationCurrent, emailCurrent bool) (bool, bool)
Extra map[string]interface{}
}
func selectorArgsFromContext(ctx *common.RuntimeContext) selectorArgs {
return selectorArgs{
Keys: ctx.Arg("keys"),
Group: ctx.Arg("group"),
All: parseBoolArg(ctx.Arg("all")),
}
}
func updateSettings(ctx *common.RuntimeContext, req updateRequest) (map[string]interface{}, error) {
login, registry, current, err := loadUserSettingContext(ctx)
if err != nil {
return nil, err
}
selectedKeys, selectedGroups, err := selectKeys(req.Selector, registry)
if err != nil {
return nil, err
}
notificationBody, emailBody := mergedBodies(current, registry)
changes := make([]settingChangeOutput, 0, len(selectedKeys))
changedKeys := make([]string, 0, len(selectedKeys))
for _, key := range selectedKeys {
meta := registry.Items[key]
notificationBefore := notificationBody[key]
emailBefore := emailBody[key]
notificationAfter, emailAfter := req.Apply(notificationBefore, emailBefore)
notificationBody[key] = notificationAfter
emailBody[key] = emailAfter
change := settingChangeOutput{
Key: key,
ShortKey: meta.ShortKey,
Name: meta.Name,
Group: meta.Group,
GroupName: meta.GroupName,
NotificationBefore: notificationBefore,
NotificationAfter: notificationAfter,
EmailBefore: emailBefore,
EmailAfter: emailAfter,
}
changes = append(changes, change)
if notificationBefore != notificationAfter || emailBefore != emailAfter {
changedKeys = append(changedKeys, key)
}
}
result := map[string]interface{}{
"action": req.Action,
"login": login,
"user": current.User,
"dry_run": req.DryRun,
"selected_keys": selectedKeys,
"selected_groups": selectedGroups,
"changed_keys": changedKeys,
"changes": changes,
"summary": map[string]interface{}{
"selected": len(selectedKeys),
"changed": len(changedKeys),
},
"setting": map[string]interface{}{
"notification_body": notificationBody,
"email_body": emailBody,
},
}
for key, value := range req.Extra {
result[key] = value
}
if req.DryRun {
return result, nil
}
payload := map[string]interface{}{
"setting": map[string]interface{}{
"notification_body": notificationBody,
"email_body": emailBody,
},
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/api/users/%s/template_message_settings/update_setting", login), payload)
if err != nil {
return nil, err
}
result["updated"] = env.Data
return result, nil
}
func loadRegistry(ctx *common.RuntimeContext) (*settingRegistry, error) {
catalog, err := fetchCatalog(ctx)
if err != nil {
return nil, err
}
return buildRegistry(catalog, nil), nil
}
func loadUserSettingContext(ctx *common.RuntimeContext) (string, *settingRegistry, *userSettingResponse, error) {
login, err := resolveTargetLogin(ctx)
if err != nil {
return "", nil, nil, err
}
catalog, err := fetchCatalog(ctx)
if err != nil {
return "", nil, nil, err
}
current, err := fetchUserSettings(ctx, login)
if err != nil {
return "", nil, nil, err
}
return login, buildRegistry(catalog, current), current, nil
}
func resolveTargetLogin(ctx *common.RuntimeContext) (string, error) {
if login := strings.TrimSpace(ctx.Arg("login")); login != "" {
return login, nil
}
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return "", fmt.Errorf("fetch current user: %w", err)
}
var current currentUserResponse
if err := decodeEnvelopeData(env.Data, &current); err != nil {
return "", fmt.Errorf("parse current user: %w", err)
}
if strings.TrimSpace(current.Login) == "" {
return "", fmt.Errorf("current user response did not include a login")
}
return current.Login, nil
}
func fetchCatalog(ctx *common.RuntimeContext) (*catalogResponse, error) {
env, err := ctx.CallAPI("GET", "/api/template_message_settings", nil)
if err != nil {
return nil, fmt.Errorf("fetch message setting catalog: %w", err)
}
var catalog catalogResponse
if err := decodeEnvelopeData(env.Data, &catalog); err != nil {
return nil, fmt.Errorf("parse message setting catalog: %w", err)
}
return &catalog, nil
}
func fetchUserSettings(ctx *common.RuntimeContext, login string) (*userSettingResponse, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("/api/users/%s/template_message_settings", login), nil)
if err != nil {
return nil, fmt.Errorf("fetch user message settings: %w", err)
}
var response userSettingResponse
if err := decodeEnvelopeData(env.Data, &response); err != nil {
return nil, fmt.Errorf("parse user message settings: %w", err)
}
if response.NotificationBody == nil {
response.NotificationBody = map[string]bool{}
}
if response.EmailBody == nil {
response.EmailBody = map[string]bool{}
}
return &response, nil
}
func buildRegistry(catalog *catalogResponse, current *userSettingResponse) *settingRegistry {
registry := &settingRegistry{
OrderedGroups: []string{},
GroupNames: map[string]string{},
GroupOrder: map[string]int{},
Items: map[string]settingMeta{},
}
if catalog != nil {
for _, group := range catalog.SettingTypes {
shortGroup := shortenSettingType(group.Type)
if shortGroup == "" {
continue
}
ensureGroup(registry, shortGroup, group.TypeName)
for _, row := range group.Settings {
fullKey := shortGroup + "::" + strings.TrimSpace(row.Key)
defaultNotification := !row.NotificationDisabled
defaultEmail := !row.EmailDisabled
registry.Items[fullKey] = settingMeta{
FullKey: fullKey,
ShortKey: strings.TrimSpace(row.Key),
Group: shortGroup,
GroupName: group.TypeName,
Name: strings.TrimSpace(row.Name),
DefaultNotificationEnabled: boolPtr(defaultNotification),
DefaultEmailEnabled: boolPtr(defaultEmail),
}
}
}
}
if current != nil {
for key := range current.NotificationBody {
ensureSettingMeta(registry, key)
}
for key := range current.EmailBody {
ensureSettingMeta(registry, key)
}
}
return registry
}
func ensureGroup(registry *settingRegistry, group, name string) {
group = strings.TrimSpace(group)
if group == "" {
return
}
if _, ok := registry.GroupOrder[group]; !ok {
registry.GroupOrder[group] = len(registry.OrderedGroups)
registry.OrderedGroups = append(registry.OrderedGroups, group)
}
if strings.TrimSpace(name) != "" {
registry.GroupNames[group] = strings.TrimSpace(name)
}
}
func ensureSettingMeta(registry *settingRegistry, fullKey string) {
if _, ok := registry.Items[fullKey]; ok {
return
}
group, shortKey := splitSettingKey(fullKey)
ensureGroup(registry, group, registry.GroupNames[group])
name := shortKey
if name == "" {
name = fullKey
}
registry.Items[fullKey] = settingMeta{
FullKey: fullKey,
ShortKey: shortKey,
Group: group,
GroupName: registry.GroupNames[group],
Name: name,
}
}
func renderCatalogGroups(registry *settingRegistry, filter []string) []groupedSettingOutput {
allowedGroups := makeGroupSet(filter)
groups := make([]groupedSettingOutput, 0, len(registry.OrderedGroups))
for _, group := range orderedGroups(registry, filter) {
if len(allowedGroups) > 0 && !allowedGroups[group] {
continue
}
settings := collectSettings(registry, group)
items := make([]settingOutput, 0, len(settings))
summary := channelSummaryOutput{Total: len(settings)}
for _, meta := range settings {
item := settingOutput{
Key: meta.FullKey,
ShortKey: meta.ShortKey,
Name: meta.Name,
DefaultNotificationEnabled: meta.DefaultNotificationEnabled,
DefaultEmailEnabled: meta.DefaultEmailEnabled,
}
if meta.DefaultNotificationEnabled != nil && *meta.DefaultNotificationEnabled {
item.NotificationEnabled = true
summary.NotificationEnabled++
}
if meta.DefaultEmailEnabled != nil && *meta.DefaultEmailEnabled {
item.EmailEnabled = true
summary.EmailEnabled++
}
items = append(items, item)
}
groups = append(groups, groupedSettingOutput{
Group: group,
GroupName: registry.GroupNames[group],
Settings: items,
Summary: summary,
})
}
return groups
}
func renderUserGroups(registry *settingRegistry, current *userSettingResponse, filter []string) []groupedSettingOutput {
notificationBody, emailBody := mergedBodies(current, registry)
allowedGroups := makeGroupSet(filter)
groups := make([]groupedSettingOutput, 0, len(registry.OrderedGroups))
for _, group := range orderedGroups(registry, filter) {
if len(allowedGroups) > 0 && !allowedGroups[group] {
continue
}
settings := collectSettings(registry, group)
items := make([]settingOutput, 0, len(settings))
summary := channelSummaryOutput{Total: len(settings)}
for _, meta := range settings {
notificationEnabled := notificationBody[meta.FullKey]
emailEnabled := emailBody[meta.FullKey]
if notificationEnabled {
summary.NotificationEnabled++
}
if emailEnabled {
summary.EmailEnabled++
}
items = append(items, settingOutput{
Key: meta.FullKey,
ShortKey: meta.ShortKey,
Name: meta.Name,
NotificationEnabled: notificationEnabled,
EmailEnabled: emailEnabled,
DefaultNotificationEnabled: meta.DefaultNotificationEnabled,
DefaultEmailEnabled: meta.DefaultEmailEnabled,
})
}
groups = append(groups, groupedSettingOutput{
Group: group,
GroupName: registry.GroupNames[group],
Settings: items,
Summary: summary,
})
}
return groups
}
func summarizeCatalogGroups(groups []groupedSettingOutput) channelSummaryOutput {
summary := channelSummaryOutput{}
for _, group := range groups {
summary.Total += group.Summary.Total
summary.NotificationEnabled += group.Summary.NotificationEnabled
summary.EmailEnabled += group.Summary.EmailEnabled
}
return summary
}
func summarizeUserGroups(groups []groupedSettingOutput) channelSummaryOutput {
return summarizeCatalogGroups(groups)
}
func selectKeys(args selectorArgs, registry *settingRegistry) ([]string, []string, error) {
selected := map[string]bool{}
groupFilter, err := parseGroupFilter(args.Group, registry)
if err != nil {
return nil, nil, err
}
for _, group := range groupFilter {
for _, meta := range collectSettings(registry, group) {
selected[meta.FullKey] = true
}
}
keys, err := resolveKeyFilter(args.Keys, registry)
if err != nil {
return nil, nil, err
}
for _, key := range keys {
selected[key] = true
}
if args.All {
for key := range registry.Items {
selected[key] = true
}
}
if len(selected) == 0 {
return nil, nil, fmt.Errorf("one of --keys, --group, or --all is required")
}
selectedKeys := make([]string, 0, len(selected))
for key := range selected {
selectedKeys = append(selectedKeys, key)
}
sortKeys(registry, selectedKeys)
selectedGroupsSet := map[string]bool{}
for _, key := range selectedKeys {
selectedGroupsSet[registry.Items[key].Group] = true
}
selectedGroups := make([]string, 0, len(selectedGroupsSet))
for _, group := range registry.OrderedGroups {
if selectedGroupsSet[group] {
selectedGroups = append(selectedGroups, group)
}
}
return selectedKeys, selectedGroups, nil
}
func parseGroupFilter(value string, registry *settingRegistry) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
groupLookup := map[string]string{}
for _, group := range registry.OrderedGroups {
groupLookup[strings.ToLower(group)] = group
}
parts := strings.Split(value, ",")
groups := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
token := strings.ToLower(strings.TrimSpace(part))
if token == "" {
continue
}
group, ok := groupLookup[token]
if !ok {
return nil, fmt.Errorf("unknown --group value %q", strings.TrimSpace(part))
}
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
return groups, nil
}
func resolveKeyFilter(value string, registry *settingRegistry) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
shortLookup := map[string][]string{}
fullLookup := map[string]string{}
for key, meta := range registry.Items {
shortLookup[strings.ToLower(meta.ShortKey)] = append(shortLookup[strings.ToLower(meta.ShortKey)], key)
fullLookup[strings.ToLower(meta.FullKey)] = key
}
parts := strings.Split(value, ",")
keys := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
token := strings.TrimSpace(part)
if token == "" {
continue
}
var resolved string
if strings.Contains(token, "::") {
var ok bool
resolved, ok = fullLookup[strings.ToLower(token)]
if !ok {
return nil, fmt.Errorf("unknown --keys value %q", token)
}
} else {
matches := shortLookup[strings.ToLower(token)]
switch len(matches) {
case 0:
return nil, fmt.Errorf("unknown --keys value %q", token)
case 1:
resolved = matches[0]
default:
return nil, fmt.Errorf("ambiguous --keys value %q; use a full Group::Key value", token)
}
}
if seen[resolved] {
continue
}
seen[resolved] = true
keys = append(keys, resolved)
}
sortKeys(registry, keys)
return keys, nil
}
func mergedBodies(current *userSettingResponse, registry *settingRegistry) (map[string]bool, map[string]bool) {
notificationBody := map[string]bool{}
emailBody := map[string]bool{}
for key, value := range current.NotificationBody {
notificationBody[key] = value
}
for key, value := range current.EmailBody {
emailBody[key] = value
}
for key, meta := range registry.Items {
if _, ok := notificationBody[key]; !ok {
if meta.DefaultNotificationEnabled != nil {
notificationBody[key] = *meta.DefaultNotificationEnabled
} else {
notificationBody[key] = false
}
}
if _, ok := emailBody[key]; !ok {
if meta.DefaultEmailEnabled != nil {
emailBody[key] = *meta.DefaultEmailEnabled
} else {
emailBody[key] = false
}
}
}
return notificationBody, emailBody
}
func collectSettings(registry *settingRegistry, group string) []settingMeta {
settings := []settingMeta{}
for _, meta := range registry.Items {
if meta.Group != group {
continue
}
settings = append(settings, meta)
}
sort.Slice(settings, func(i, j int) bool {
return strings.ToLower(settings[i].ShortKey) < strings.ToLower(settings[j].ShortKey)
})
return settings
}
func orderedGroups(registry *settingRegistry, filter []string) []string {
if len(filter) == 0 {
return append([]string(nil), registry.OrderedGroups...)
}
return append([]string(nil), filter...)
}
func sortKeys(registry *settingRegistry, keys []string) {
sort.Slice(keys, func(i, j int) bool {
left := registry.Items[keys[i]]
right := registry.Items[keys[j]]
leftOrder := registry.GroupOrder[left.Group]
rightOrder := registry.GroupOrder[right.Group]
if leftOrder != rightOrder {
return leftOrder < rightOrder
}
return strings.ToLower(left.ShortKey) < strings.ToLower(right.ShortKey)
})
}
func parseChannel(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case channelNotification:
return channelNotification, nil
case channelEmail:
return channelEmail, nil
case channelBoth:
return channelBoth, nil
default:
return "", fmt.Errorf("invalid --channel value %q", value)
}
}
func parseState(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "on", "true", "enable", "enabled":
return true, nil
case "off", "false", "disable", "disabled":
return false, nil
default:
return false, fmt.Errorf("invalid --state value %q", value)
}
}
func parseBoolArg(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "true")
}
func splitSettingKey(fullKey string) (string, string) {
parts := strings.SplitN(strings.TrimSpace(fullKey), "::", 2)
if len(parts) != 2 {
return strings.TrimSpace(fullKey), strings.TrimSpace(fullKey)
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
func shortenSettingType(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.Split(value, "::")
return strings.TrimSpace(parts[len(parts)-1])
}
func makeGroupSet(groups []string) map[string]bool {
if len(groups) == 0 {
return nil
}
result := map[string]bool{}
for _, group := range groups {
result[group] = true
}
return result
}
func boolPtr(value bool) *bool {
return &value
}
func decodeEnvelopeData(data interface{}, target interface{}) error {
raw, err := json.Marshal(data)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}

View File

@ -0,0 +1,336 @@
package messagesetting
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runMessageSettingShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findMessageSettingShortcut(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 findMessageSettingShortcut(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 writeMessageSettingJSON(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 decodeMessageSettingJSON(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 TestMessageSettingsCatalog(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/template_message_settings.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeMessageSettingJSON(t, w, catalogFixture())
}))
defer server.Close()
if err := runMessageSettingShortcut(t, server, "catalog", nil); err != nil {
t.Fatalf("catalog failed: %v", err)
}
}
func TestMessageSettingsViewUsesCurrentUserWhenLoginMissing(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/users/me.json":
writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"})
case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json":
writeMessageSettingJSON(t, w, catalogFixture())
case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json":
writeMessageSettingJSON(t, w, userSettingFixture())
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runMessageSettingShortcut(t, server, "view", nil); err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestMessageSettingsUpdateDryRunDoesNotPost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/users/me.json":
writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"})
case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json":
writeMessageSettingJSON(t, w, catalogFixture())
case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json":
writeMessageSettingJSON(t, w, userSettingFixture())
default:
t.Fatalf("dry-run should not write, got %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runMessageSettingShortcut(t, server, "update", map[string]string{
"channel": "notification",
"state": "off",
"keys": "Normal::Permission",
"dry-run": "true",
})
if err != nil {
t.Fatalf("update dry-run failed: %v", err)
}
}
func TestMessageSettingsUpdatePostsMergedPayload(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/users/me.json":
writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"})
case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json":
writeMessageSettingJSON(t, w, catalogFixture())
case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json":
writeMessageSettingJSON(t, w, userSettingFixture())
case r.Method == http.MethodPost && r.URL.Path == "/api/users/alice/template_message_settings/update_setting.json":
payload = decodeMessageSettingJSON(t, r)
writeMessageSettingJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runMessageSettingShortcut(t, server, "update", map[string]string{
"channel": "email",
"state": "on",
"group": "ManageProject",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
setting, ok := payload["setting"].(map[string]interface{})
if !ok {
t.Fatalf("missing setting payload: %#v", payload)
}
notificationBody := setting["notification_body"].(map[string]interface{})
emailBody := setting["email_body"].(map[string]interface{})
if notificationBody["ManageProject::Issue"] != true {
t.Fatalf("notification should stay true for ManageProject::Issue, got %#v", notificationBody["ManageProject::Issue"])
}
if emailBody["ManageProject::Issue"] != true {
t.Fatalf("email should be enabled for ManageProject::Issue, got %#v", emailBody["ManageProject::Issue"])
}
if emailBody["Normal::Permission"] != false {
t.Fatalf("email should preserve unrelated keys, got %#v", emailBody["Normal::Permission"])
}
if notificationBody["ManageProject::Praised"] != true {
t.Fatalf("notification defaults should be preserved for missing keys, got %#v", notificationBody["ManageProject::Praised"])
}
if emailBody["ManageProject::Praised"] != true {
t.Fatalf("selected group keys should be updated even when missing from current settings, got %#v", emailBody["ManageProject::Praised"])
}
}
func TestMessageSettingsPresetPostsSelectedKeys(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/users/me.json":
writeMessageSettingJSON(t, w, map[string]interface{}{"login": "alice"})
case r.Method == http.MethodGet && r.URL.Path == "/api/template_message_settings.json":
writeMessageSettingJSON(t, w, catalogFixture())
case r.Method == http.MethodGet && r.URL.Path == "/api/users/alice/template_message_settings.json":
writeMessageSettingJSON(t, w, userSettingFixture())
case r.Method == http.MethodPost && r.URL.Path == "/api/users/alice/template_message_settings/update_setting.json":
payload = decodeMessageSettingJSON(t, r)
writeMessageSettingJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runMessageSettingShortcut(t, server, "preset", map[string]string{
"name": "all-off",
"keys": "Permission,ManageProject::Issue",
"all": "false",
})
if err != nil {
t.Fatalf("preset failed: %v", err)
}
setting := payload["setting"].(map[string]interface{})
notificationBody := setting["notification_body"].(map[string]interface{})
emailBody := setting["email_body"].(map[string]interface{})
if notificationBody["Normal::Permission"] != false || emailBody["Normal::Permission"] != false {
t.Fatalf("Normal::Permission should be turned off by preset")
}
if notificationBody["ManageProject::Issue"] != false || emailBody["ManageProject::Issue"] != false {
t.Fatalf("ManageProject::Issue should be turned off by preset")
}
if notificationBody["ManageProject::PullRequest"] != true {
t.Fatalf("unselected keys should remain unchanged, got %#v", notificationBody["ManageProject::PullRequest"])
}
}
func TestSelectKeysRequiresSelector(t *testing.T) {
registry := buildRegistry(mustCatalogResponse(t, catalogFixture()), mustUserSettingResponse(t, userSettingFixture()))
if _, _, err := selectKeys(selectorArgs{}, registry); err == nil {
t.Fatal("expected selector validation error")
}
}
func TestParseGroupFilterRejectsUnknownGroup(t *testing.T) {
registry := buildRegistry(mustCatalogResponse(t, catalogFixture()), nil)
if _, err := parseGroupFilter("UnknownGroup", registry); err == nil {
t.Fatal("expected unknown group error")
}
}
func TestResolveKeyFilterRejectsAmbiguousShortKey(t *testing.T) {
catalog := &catalogResponse{
SettingTypes: []catalogGroup{
{
Type: "TemplateMessageSetting::Normal",
TypeName: "Normal",
Settings: []catalogSettingRow{
{Name: "Issue", Key: "Issue"},
},
},
{
Type: "TemplateMessageSetting::ManageProject",
TypeName: "Manage",
Settings: []catalogSettingRow{
{Name: "Issue", Key: "Issue"},
},
},
},
}
registry := buildRegistry(catalog, nil)
if _, err := resolveKeyFilter("Issue", registry); err == nil {
t.Fatal("expected ambiguous key error")
}
}
func catalogFixture() map[string]interface{} {
return map[string]interface{}{
"setting_types": []map[string]interface{}{
{
"type": "TemplateMessageSetting::Normal",
"type_name": "My status",
"settings": []map[string]interface{}{
{
"name": "Permission changed",
"key": "Permission",
"notification_disabled": false,
"email_disabled": false,
},
},
},
{
"type": "TemplateMessageSetting::ManageProject",
"type_name": "Managed repositories",
"settings": []map[string]interface{}{
{
"name": "New issue",
"key": "Issue",
"notification_disabled": false,
"email_disabled": false,
},
{
"name": "New pull request",
"key": "PullRequest",
"notification_disabled": false,
"email_disabled": false,
},
{
"name": "Praised",
"key": "Praised",
"notification_disabled": false,
"email_disabled": true,
},
},
},
},
}
}
func userSettingFixture() map[string]interface{} {
return map[string]interface{}{
"user": map[string]interface{}{
"login": "alice",
"name": "Alice",
},
"notification_body": map[string]interface{}{
"Normal::Permission": true,
"ManageProject::Issue": true,
"ManageProject::PullRequest": true,
},
"email_body": map[string]interface{}{
"Normal::Permission": false,
"ManageProject::Issue": false,
"ManageProject::PullRequest": false,
},
}
}
func mustCatalogResponse(t *testing.T, value map[string]interface{}) *catalogResponse {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal catalog fixture: %v", err)
}
var result catalogResponse
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatalf("unmarshal catalog fixture: %v", err)
}
return &result
}
func mustUserSettingResponse(t *testing.T, value map[string]interface{}) *userSettingResponse {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal user setting fixture: %v", err)
}
var result userSettingResponse
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatalf("unmarshal user setting fixture: %v", err)
}
return &result
}

View File

@ -15,6 +15,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/messagesetting"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/notification"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
@ -37,55 +38,57 @@ 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(),
"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(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"message-settings": messagesetting.Shortcuts(tr),
"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",
"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",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"message-settings": tr.T("cmd.message_settings.short"),
"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 {