mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
fix(notification): read the inbox by default and stream watch as NDJSON
notification commands required a repository unless --mine was given.
They now act on the account's inbox (D17); -R, TEA_REPO or the new
--this-repo limit them to one repository, and --mine is a hidden no-op.
notification list is a table with ID, TYPE, REPOSITORY, SUBJECT ("#17
Improve greeting", bold when unread), STATE and UPDATED, a "Showing N of
M" line and an empty message on a terminal; piped rows add the status
and the URL. The ID links to the subject. JSON gains the subject number.
notification view shows the subject, its state and the latest comment,
then marks an unread notification as read. mark-read, mark-unread, pin
and unpin report each change ("Pinned notification 12
(alice/project#17)"), and --all confirms on a terminal and reports the
count. notification watch --json printed one array when it exited; it
now prints each change as one JSON object per line, prints a line per
change on a terminal, and runs until Ctrl-C there instead of stopping
after a minute.
Status and type filters complete and validate their values, notification
IDs complete from the inbox, and every command has examples.
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
6 files changed
+569
-200
No files matched your search
@@ -415,8 +415,6 @@ func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) {
|
||||
// have yet to complete; see TestHelpTextsAreComplete.
|
||||
const (
|
||||
shortsToFix = `
|
||||
notification mark-read
|
||||
notification mark-unread
|
||||
pr review cancel-request
|
||||
`
|
||||
examplesToWrite = `
|
||||
@@ -459,13 +457,6 @@ const (
|
||||
milestone list
|
||||
milestone reopen
|
||||
milestone view
|
||||
notification list
|
||||
notification mark-read
|
||||
notification mark-unread
|
||||
notification pin
|
||||
notification unpin
|
||||
notification view
|
||||
notification watch
|
||||
pr attachment delete
|
||||
pr attachment download
|
||||
pr attachment edit
|
||||
@@ -547,7 +538,6 @@ const (
|
||||
label
|
||||
milestone
|
||||
milestone issue
|
||||
notification
|
||||
pr
|
||||
pr attachment
|
||||
pr comment
|
||||
|
||||
+383
-153
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,173 +18,404 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// notificationScope is the notifications a command acts on: the account's inbox, or one repository's.
|
||||
type notificationScope struct {
|
||||
services.Notifications
|
||||
// where names the scope in messages: "in your inbox" or "in alice/project".
|
||||
where string
|
||||
}
|
||||
|
||||
func (a *application) notificationCommand() *cobra.Command {
|
||||
group := &cobra.Command{Use: "notification", Short: "Manage account notifications"}
|
||||
var mine bool
|
||||
group.PersistentFlags().BoolVar(&mine, "mine", false, "Use all your notifications instead of repository notifications")
|
||||
service := func(cmd *cobra.Command, requireRepo bool) (services.Notifications, error) {
|
||||
if mine && a.options.repo != "" {
|
||||
return services.Notifications{}, clierr.Wrap(clierr.Usage, errors.New("--mine and --repo are mutually exclusive"))
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), requireRepo && !mine)
|
||||
group := &cobra.Command{Use: "notification", Short: "Read and triage your notifications", Long: `Read and triage the notifications of your account.
|
||||
|
||||
Commands act on your whole inbox. -R OWNER/REPO limits them to one repository, and
|
||||
--this-repo to the repository of the current directory. The IDs that tea notification
|
||||
list shows identify notification threads; they are not issue numbers.`}
|
||||
var mine, thisRepo bool
|
||||
group.PersistentFlags().BoolVar(&mine, "mine", false, "Read your whole inbox (the default)")
|
||||
_ = group.PersistentFlags().MarkHidden("mine")
|
||||
group.PersistentFlags().BoolVar(&thisRepo, "this-repo", false, "Only notifications of the repository in the current directory")
|
||||
scope := func(cmd *cobra.Command) (notificationScope, error) {
|
||||
t, err := a.resolve(cmd.Context(), thisRepo)
|
||||
if err != nil {
|
||||
return services.Notifications{}, err
|
||||
return notificationScope{}, err
|
||||
}
|
||||
repository := ""
|
||||
if (requireRepo || a.options.repo != "") && !mine {
|
||||
repository = target.Repo
|
||||
where := "in your inbox"
|
||||
if t.Repo != "" {
|
||||
where = "in " + t.Repo
|
||||
}
|
||||
return services.Notifications{Client: target.client, Repository: repository}, nil
|
||||
return notificationScope{services.Notifications{Client: t.client, Repository: t.Repo}, where}, nil
|
||||
}
|
||||
for _, verb := range []string{"list", "watch"} {
|
||||
opts := services.NotificationOptions{}
|
||||
var updated timeRange
|
||||
var interval, timeout time.Duration
|
||||
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " notifications", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateNotificationStatuses(opts.Statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, kind := range opts.Types {
|
||||
kind = strings.ToLower(kind)
|
||||
if !slices.Contains([]string{"issue", "pull", "commit", "repository"}, kind) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--type must contain issue, pull, commit, or repository"))
|
||||
}
|
||||
opts.Types[i] = titleCase(kind)
|
||||
}
|
||||
var err error
|
||||
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if verb == "watch" && (interval <= 0 || timeout <= 0) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
||||
}
|
||||
s, err := service(cmd, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if verb == "list" {
|
||||
items, err := s.List(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, items, notificationRow)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
||||
defer cancel()
|
||||
results := []services.Notification{}
|
||||
color := a.colorEnabled()
|
||||
err = s.Watch(ctx, opts, interval, func(items []services.Notification) error {
|
||||
if a.options.json != "" {
|
||||
results = append(results, items...)
|
||||
return nil
|
||||
}
|
||||
_, err := io.WriteString(cmd.OutOrStdout(), table(items, notificationRow, color))
|
||||
return err
|
||||
})
|
||||
if errors.Is(err, context.DeadlineExceeded) && cmd.Context().Err() == nil {
|
||||
err = nil
|
||||
}
|
||||
if a.options.json != "" {
|
||||
if outputErr := a.writeJSON(cmd, results); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}}
|
||||
pageFlags(command, &opts.Limit, &opts.Page)
|
||||
command.Flags().StringSliceVar(&opts.Statuses, "status", []string{"unread", "pinned"}, "Filter unread, read, or pinned")
|
||||
command.Flags().StringSliceVar(&opts.Types, "type", nil, "Filter issue, pull, commit, or repository")
|
||||
updated.register(command, "since", "Updated since a date, timestamp, or duration such as 24h", "before", "Updated before a date, timestamp, or duration such as 1h")
|
||||
if verb == "watch" {
|
||||
command.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval")
|
||||
command.Flags().DurationVar(&timeout, "timeout", time.Minute, "Maximum watch duration")
|
||||
}
|
||||
group.AddCommand(command)
|
||||
}
|
||||
group.AddCommand(&cobra.Command{Use: "view <id>", Short: "View a notification thread", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := service(cmd, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := s.View(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeItem(a, cmd, item, notificationRow)
|
||||
}})
|
||||
for _, mark := range []struct{ verb, status, filter string }{
|
||||
{"mark-read", "read", "unread"}, {"mark-unread", "unread", "read"}, {"pin", "pinned", "unread"}, {"unpin", "read", "pinned"},
|
||||
group.AddCommand(a.notificationListCommand(scope), a.notificationWatchCommand(scope), a.notificationViewCommand(scope))
|
||||
for _, mark := range []struct {
|
||||
verb, status, filter, short, done, example string
|
||||
}{
|
||||
{"mark-read", "read", "unread", "Mark notifications as read", "Marked %s as read", "$ tea notification mark-read 28\n# Everything in one repository\n$ tea notification mark-read --all -R alice/project"},
|
||||
{"mark-unread", "unread", "read", "Mark notifications as unread", "Marked %s as unread", "$ tea notification mark-unread 28 26"},
|
||||
{"pin", "pinned", "unread", "Pin notifications", "Pinned %s", "$ tea notification pin 28"},
|
||||
{"unpin", "read", "pinned", "Unpin notifications", "Unpinned %s", "$ tea notification unpin 28\n# Unpin everything\n$ tea notification unpin --all"},
|
||||
} {
|
||||
verb := mark.verb
|
||||
var all bool
|
||||
var filters []string
|
||||
var before string
|
||||
command := &cobra.Command{Use: verb + " [id]...", Short: titleCase(verb) + " selected or filtered notifications", Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 && !all {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
|
||||
}
|
||||
if len(args) > 0 && all {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
|
||||
}
|
||||
return nil
|
||||
}, RunE: func(cmd *cobra.Command, args []string) error {
|
||||
numbers, err := resourceNumbers(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cmd.Flags().Changed("status") {
|
||||
filters = []string{mark.filter}
|
||||
}
|
||||
if err := validateNotificationStatuses(filters); err != nil {
|
||||
return err
|
||||
}
|
||||
var until time.Time
|
||||
if cmd.Flags().Changed("before") {
|
||||
parsed, err := parseRunTime(before, a.deps.Now())
|
||||
if err != nil {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --before: %w", err))
|
||||
command := &cobra.Command{
|
||||
Use: mark.verb + " [<id>...]", Short: mark.short, Example: mark.example, ValidArgsFunction: a.completeNotifications(),
|
||||
Annotations: map[string]string{"tea.confirm": "true"},
|
||||
Args: func(_ *cobra.Command, args []string) error {
|
||||
if len(args) == 0 && !all {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
|
||||
}
|
||||
until = *parsed
|
||||
}
|
||||
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
|
||||
}
|
||||
s, err := service(cmd, all)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !all {
|
||||
return batch(a, cmd, numbers, func(id int64) string { return fmt.Sprintf("notification %d", id) }, func(id int64) (services.Notification, error) {
|
||||
return s.Mark(cmd.Context(), id, mark.status)
|
||||
}, notificationRow)
|
||||
}
|
||||
results, err := s.MarkAll(cmd.Context(), mark.status, filters, until)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, results, notificationRow)
|
||||
}}
|
||||
command.Flags().BoolVar(&all, "all", false, "Apply to all notifications matching filters in the selected scope")
|
||||
command.Flags().StringSliceVar(&filters, "status", nil, "Filter statuses for --all")
|
||||
command.Flags().StringVar(&before, "before", "", "Updated before a date, timestamp, or duration (requires --all)")
|
||||
if len(args) > 0 && all {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ids, err := resourceNumbers(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
|
||||
}
|
||||
if !cmd.Flags().Changed("status") {
|
||||
filters = []string{mark.filter}
|
||||
}
|
||||
var until time.Time
|
||||
if cmd.Flags().Changed("before") {
|
||||
parsed, err := parseRunTime(before, a.deps.Now())
|
||||
if err != nil {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --before: %w", err))
|
||||
}
|
||||
until = *parsed
|
||||
}
|
||||
s, err := scope(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !all {
|
||||
return batchDone(a, cmd, ids, func(id int64) string { return fmt.Sprintf("notification %d", id) }, func(id int64) (services.Notification, error) {
|
||||
return s.Mark(cmd.Context(), id, mark.status)
|
||||
}, func(item services.Notification) string { return fmt.Sprintf(mark.done, notificationLabel(item)) })
|
||||
}
|
||||
what := notificationsWith(filters) + " " + s.where
|
||||
if settings, err := a.deps.Settings.Load(); err == nil && a.policy(settings).CanPrompt(a.deps.Streams) {
|
||||
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("%s all %s?", strings.Replace(mark.done, " %s", "", 1), what), DefaultYes: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
results, err := s.MarkAll(cmd.Context(), mark.status, filters, until)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, results)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
a.warn(cmd, "No %s", what)
|
||||
return nil
|
||||
}
|
||||
a.done(cmd, mark.done, plural(len(results), "notification"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
command.Flags().BoolVar(&all, "all", false, "Apply to every notification that matches --status in the scope")
|
||||
command.Flags().StringSliceVar(&filters, "status", nil, "With --all, the statuses to change")
|
||||
enumValues(command, "status", "unread", "read", "pinned")
|
||||
command.Flags().StringVar(&before, "before", "", "With --all, only notifications updated before a date, timestamp, or duration such as 1h")
|
||||
group.AddCommand(command)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func validateNotificationStatuses(statuses []string) error {
|
||||
for _, status := range statuses {
|
||||
if !slices.Contains([]string{"unread", "read", "pinned"}, status) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--status must contain unread, read, or pinned"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// notificationFilters registers the list filters of list and watch.
|
||||
func notificationFilters(cmd *cobra.Command, opts *services.NotificationOptions, updated *timeRange) {
|
||||
pageFlags(cmd, &opts.Limit, &opts.Page)
|
||||
cmd.Flags().StringSliceVar(&opts.Statuses, "status", []string{"unread", "pinned"}, "Only notifications with these statuses")
|
||||
enumValues(cmd, "status", "unread", "read", "pinned")
|
||||
cmd.Flags().StringSliceVar(&opts.Types, "type", nil, "Only notifications about these subjects")
|
||||
enumValues(cmd, "type", "issue", "pull", "commit", "repository")
|
||||
updated.register(cmd, "since", "Only notifications updated since a date, timestamp, or duration such as 24h", "before", "Only notifications updated before a date, timestamp, or duration such as 1h")
|
||||
}
|
||||
|
||||
func notificationRow(item services.Notification) []terminal.Cell {
|
||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Repository, item.Title, item.URL)
|
||||
// notificationOptions completes the list filters for the API.
|
||||
func (a *application) notificationOptions(opts services.NotificationOptions, updated timeRange) (services.NotificationOptions, error) {
|
||||
types := make([]string, len(opts.Types))
|
||||
for i, kind := range opts.Types {
|
||||
types[i] = titleCase(kind)
|
||||
}
|
||||
opts.Types = types
|
||||
var err error
|
||||
opts.Since, opts.Before, err = updated.parse(a.deps.Now())
|
||||
return opts, err
|
||||
}
|
||||
|
||||
func (a *application) notificationListCommand(scope func(*cobra.Command) (notificationScope, error)) *cobra.Command {
|
||||
opts := services.NotificationOptions{}
|
||||
var updated timeRange
|
||||
command := &cobra.Command{
|
||||
Use: "list", Short: "List notifications", Args: argsExact(0),
|
||||
Example: `$ tea notification list
|
||||
# Including the ones you read, of one repository
|
||||
$ tea notification list --status unread,read,pinned -R alice/project
|
||||
# Pull requests updated today
|
||||
$ tea notification list --type pull --since 24h`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
listed, err := a.notificationOptions(opts, updated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := scope(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := s.List(cmd.Context(), listed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
what := notificationsWith(opts.Statuses) + " " + s.where
|
||||
return writeTable(a, cmd, page.Items, listSpec[services.Notification]{
|
||||
Columns: notificationColumns, Row: notificationRow, Empty: "no " + what, Context: what,
|
||||
}, listPage{Number: opts.Page, More: page.More, Total: page.Total, Counted: page.Counted})
|
||||
},
|
||||
}
|
||||
notificationFilters(command, &opts, &updated)
|
||||
return command
|
||||
}
|
||||
|
||||
func (a *application) notificationWatchCommand(scope func(*cobra.Command) (notificationScope, error)) *cobra.Command {
|
||||
opts := services.NotificationOptions{}
|
||||
var updated timeRange
|
||||
var interval, timeout time.Duration
|
||||
command := &cobra.Command{
|
||||
Use: "watch", Short: "Print notifications as they arrive", Args: argsExact(0),
|
||||
Long: `Print notifications as they arrive or change, until Ctrl-C or --timeout. On a terminal
|
||||
it runs until stopped; otherwise it stops after a minute unless --timeout says otherwise.
|
||||
With --json it prints one JSON object per line.`,
|
||||
Example: "$ tea notification watch\n# Stream new pull request notifications to a script\n$ tea notification watch --type pull --json id,repository,title | ./notify.sh",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
listed, err := a.notificationOptions(opts, updated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if interval <= 0 || timeout < 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval must be positive and --timeout not negative"))
|
||||
}
|
||||
if !cmd.Flags().Changed("timeout") && !a.deps.Streams.OutTTY {
|
||||
timeout = time.Minute
|
||||
}
|
||||
s, err := scope(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
d := a.display()
|
||||
if d.TTY && a.options.json == "" {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Watching %s %s; press Ctrl-C to stop\n", notificationsWith(opts.Statuses), s.where)
|
||||
}
|
||||
err = s.Watch(ctx, listed, interval, func(items []services.Notification) error {
|
||||
if a.options.json != "" {
|
||||
for _, item := range items {
|
||||
if err := (terminal.JSONOutput{Fields: a.options.json, Query: a.options.jq}).Write(cmd.OutOrStdout(), item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.TTY {
|
||||
rows := make([][]terminal.Cell, len(items))
|
||||
for i, item := range items {
|
||||
rows[i] = notificationRow(item)
|
||||
}
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), terminal.Table(notificationColumns, rows, d))
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), watchLine(item, d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, context.DeadlineExceeded) && cmd.Context().Err() == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
notificationFilters(command, &opts, &updated)
|
||||
command.Flags().DurationVar(&interval, "interval", 5*time.Second, "Time between checks")
|
||||
command.Flags().DurationVar(&timeout, "timeout", 0, "Stop after this long (default: never on a terminal, 1m otherwise)")
|
||||
return command
|
||||
}
|
||||
|
||||
func (a *application) notificationViewCommand(scope func(*cobra.Command) (notificationScope, error)) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "view <id>", Short: "View a notification and mark it as read", Args: argsExact(1), ValidArgsFunction: a.completeNotifications(),
|
||||
Long: `View a notification: its subject, the subject's state and the latest comment. Like
|
||||
opening it on the web, viewing marks an unread notification as read; pinned ones stay
|
||||
pinned.`,
|
||||
Example: "$ tea notification view 28\n$ tea notification view 28 --json title,url",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := scope(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := s.View(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var comment *services.Comment
|
||||
if a.options.json == "" {
|
||||
if comment, err = s.LatestComment(cmd.Context(), item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeView(a, cmd, item, func(item services.Notification) terminal.View { return notificationView(item, comment, a.display()) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Unread && !item.Pinned {
|
||||
_, err = s.Mark(cmd.Context(), id, "read")
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// notificationColumns head notification tables; piped, they add the status and the URL.
|
||||
var notificationColumns = []terminal.Column{
|
||||
{Header: "ID"},
|
||||
{Header: "Type"},
|
||||
{Header: "Repository"},
|
||||
{Header: "Subject", Flex: true},
|
||||
{Header: "State"},
|
||||
{Header: "Status", PipedOnly: true},
|
||||
{Header: "Updated"},
|
||||
{Header: "URL", PipedOnly: true},
|
||||
}
|
||||
|
||||
// notificationRow shows a notification under notificationColumns; unread subjects are bold.
|
||||
func notificationRow(item services.Notification) []terminal.Cell {
|
||||
return []terminal.Cell{
|
||||
{Text: strconv.FormatInt(item.ID, 10), Link: item.URL},
|
||||
{Text: strings.ToLower(item.Type)},
|
||||
{Text: item.Repository},
|
||||
{Text: notificationSubject(item), Bold: item.Unread, Muted: !item.Unread && !item.Pinned},
|
||||
{Text: item.State, Color: stateColors[item.State]},
|
||||
{Text: notificationStatus(item)},
|
||||
{Time: terminal.ParseTime(item.UpdatedAt)},
|
||||
{Text: item.URL},
|
||||
}
|
||||
}
|
||||
|
||||
// notificationSubject reads "#17 Improve greeting" for issues and pull requests.
|
||||
func notificationSubject(item services.Notification) string {
|
||||
if item.Number == 0 {
|
||||
return item.Title
|
||||
}
|
||||
return fmt.Sprintf("#%d %s", item.Number, item.Title)
|
||||
}
|
||||
|
||||
// notificationsWith names the notifications with one of statuses: "unread or pinned notifications",
|
||||
// or "notifications" for every status.
|
||||
func notificationsWith(statuses []string) string {
|
||||
var distinct []string
|
||||
for _, status := range statuses {
|
||||
if !slices.Contains(distinct, status) {
|
||||
distinct = append(distinct, status)
|
||||
}
|
||||
}
|
||||
switch len(distinct) {
|
||||
case 0, 3:
|
||||
return "notifications"
|
||||
case 1:
|
||||
return distinct[0] + " notifications"
|
||||
}
|
||||
return distinct[0] + " or " + distinct[1] + " notifications"
|
||||
}
|
||||
|
||||
// notificationStatus is unread, read or pinned.
|
||||
func notificationStatus(item services.Notification) string {
|
||||
switch {
|
||||
case item.Pinned:
|
||||
return "pinned"
|
||||
case item.Unread:
|
||||
return "unread"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
// notificationLabel names a notification in feedback: "notification 28 (alice/project#17)".
|
||||
func notificationLabel(item services.Notification) string {
|
||||
subject := item.Repository
|
||||
if item.Number != 0 {
|
||||
subject += "#" + strconv.FormatInt(item.Number, 10)
|
||||
}
|
||||
return fmt.Sprintf("notification %d (%s)", item.ID, strings.TrimSpace(subject))
|
||||
}
|
||||
|
||||
// notificationKinds names the subject types in a view footer.
|
||||
var notificationKinds = map[string]string{"Issue": "issue", "Pull": "pull request", "Commit": "commit", "Repository": "repository"}
|
||||
|
||||
// notificationView shows a notification with the latest comment on its subject as the body.
|
||||
func notificationView(item services.Notification, comment *services.Comment, d terminal.Display) terminal.View {
|
||||
meta := item.Repository
|
||||
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
|
||||
meta += " · updated " + terminal.RelativeTime(d.Now, updated)
|
||||
}
|
||||
view := terminal.View{
|
||||
Title: item.Title, Meta: meta, Kind: notificationKinds[item.Type], URL: item.URL,
|
||||
Badges: []terminal.Cell{{Text: titleCase(item.State), Color: stateColors[item.State]}, {Text: notificationStatus(item), Muted: true}},
|
||||
Fields: []terminal.Field{{Label: "Notification", Value: strconv.FormatInt(item.ID, 10)}, {Label: "Type", Value: notificationKinds[item.Type]}, {Label: "Repository", Value: item.Repository}},
|
||||
Body: "_No comments yet._",
|
||||
}
|
||||
if item.Number != 0 {
|
||||
view.ID = "#" + strconv.FormatInt(item.Number, 10)
|
||||
}
|
||||
if comment != nil {
|
||||
when := ""
|
||||
if created := terminal.ParseTime(comment.CreatedAt); !created.IsZero() {
|
||||
when = ", " + terminal.RelativeTime(d.Now, created)
|
||||
}
|
||||
view.Fields = append(view.Fields, terminal.Field{Label: "Latest comment", Value: comment.Author + when})
|
||||
view.Body = comment.Body
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// watchLine shows a notification that tea notification watch reports on a terminal:
|
||||
// "15:04 alice/project#17 Improve greeting pull · open".
|
||||
func watchLine(item services.Notification, d terminal.Display) string {
|
||||
when := terminal.ParseTime(item.UpdatedAt)
|
||||
if when.IsZero() {
|
||||
when = d.Now
|
||||
}
|
||||
subject := item.Repository
|
||||
if item.Number != 0 {
|
||||
subject += "#" + strconv.FormatInt(item.Number, 10)
|
||||
}
|
||||
details := strings.ToLower(item.Type)
|
||||
if item.State != "" {
|
||||
details += " · " + terminal.Colorize(stateColors[item.State], item.State, d.Color)
|
||||
}
|
||||
return terminal.Colorize("muted", when.Local().Format("15:04"), d.Color) + " " + terminal.Emphasis(terminal.SafeLine(subject), d.Color) + " " + terminal.SafeLine(item.Title) + " " + details
|
||||
}
|
||||
|
||||
// completeNotifications completes the IDs of the notifications in your inbox with their subjects.
|
||||
func (a *application) completeNotifications() cobra.CompletionFunc {
|
||||
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
||||
page, err := services.Notifications{Client: t.client, Repository: t.Repo}.List(ctx, services.NotificationOptions{Limit: completionLimit, Page: 1, Statuses: []string{"unread", "pinned", "read"}})
|
||||
return describe(page.Items, func(item services.Notification) (string, string) {
|
||||
return strconv.FormatInt(item.ID, 10), strings.TrimPrefix(notificationLabel(item)+" "+item.Title, "notification "+strconv.FormatInt(item.ID, 10)+" ")
|
||||
}), err
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -33,3 +34,107 @@ func TestNotificationBoundsShareClockAndReachServer(t *testing.T) {
|
||||
require.Equal(t, 1, calls)
|
||||
require.JSONEq(t, `[]`, out.String())
|
||||
}
|
||||
|
||||
// notificationThreads is a page of two notification threads as Gitea sends them.
|
||||
const notificationThreads = `[
|
||||
{"id":12,"unread":true,"repository":{"full_name":"alice/project"},"updated_at":"2026-10-11T18:00:00Z",
|
||||
"subject":{"title":"Improve greeting","type":"Pull","state":"open","html_url":"https://gitea.test/alice/project/pulls/17","url":"https://gitea.test/api/v1/repos/alice/project/issues/17"}},
|
||||
{"id":3,"unread":false,"pinned":true,"repository":{"full_name":"alice/project"},"updated_at":"2026-10-11T17:00:00Z",
|
||||
"subject":{"title":"Improve error messages","type":"Issue","state":"closed","html_url":"https://gitea.test/alice/project/issues/8","url":"https://gitea.test/api/v1/repos/alice/project/issues/8"}}
|
||||
]`
|
||||
|
||||
func TestNotificationsListTheInboxByDefault(t *testing.T) {
|
||||
var path atomic.Value
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path.Store(r.URL.Path)
|
||||
w.Header().Set("X-Total-Count", "2")
|
||||
_, _ = w.Write([]byte(notificationThreads))
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
git map[string]string
|
||||
path string
|
||||
}{
|
||||
{nil, nil, "/api/v1/notifications"},
|
||||
{nil, map[string]string{"remote": "origin", "remote get-url origin": server.URL + "/alice/project.git"}, "/api/v1/notifications"},
|
||||
{[]string{"-R", "alice/project"}, nil, "/api/v1/repos/alice/project/notifications"},
|
||||
{[]string{"--this-repo"}, map[string]string{"remote": "origin", "remote get-url origin": server.URL + "/bob/fork.git"}, "/api/v1/repos/bob/fork/notifications"},
|
||||
} {
|
||||
deps, _, _ := serverDeps(t, server.URL)
|
||||
deps.Git = &fakeGit{results: tc.git}
|
||||
require.NoError(t, Execute(context.Background(), deps, append([]string{"notification", "list"}, tc.args...)), tc.args)
|
||||
require.Equal(t, tc.path, path.Load(), tc.args)
|
||||
}
|
||||
deps, out, _ := serverDeps(t, server.URL)
|
||||
deps.Streams.OutTTY, deps.Streams.Width = true, 100
|
||||
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"notification", "list"}))
|
||||
require.Equal(t, `Showing 2 of 2 unread or pinned notifications in your inbox
|
||||
|
||||
ID TYPE REPOSITORY SUBJECT STATE UPDATED
|
||||
12 pull alice/project #17 Improve greeting open about 2 hours ago
|
||||
3 issue alice/project #8 Improve error messages closed about 3 hours ago
|
||||
`, stripLinks(withoutANSI(out.String())))
|
||||
deps, out, _ = serverDeps(t, server.URL)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"notification", "list"}))
|
||||
require.Equal(t, "12\tpull\talice/project\t#17 Improve greeting\topen\tunread\t2026-10-11T18:00:00Z\thttps://gitea.test/alice/project/pulls/17\n"+
|
||||
"3\tissue\talice/project\t#8 Improve error messages\tclosed\tpinned\t2026-10-11T17:00:00Z\thttps://gitea.test/alice/project/issues/8\n", out.String())
|
||||
}
|
||||
|
||||
func TestNotificationWatchStreamsOneJSONObjectPerLine(t *testing.T) {
|
||||
var polls atomic.Int32
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if polls.Add(1) == 1 {
|
||||
_, _ = w.Write([]byte(`[{"id":1,"subject":{"title":"First"}}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":1,"subject":{"title":"First"}},{"id":2,"subject":{"title":"Second"}}]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, _ := serverDeps(t, server.URL)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"notification", "watch", "--json", "id,title", "--interval", "20ms", "--timeout", "500ms"}))
|
||||
require.Equal(t, "{\"id\":1,\"title\":\"First\"}\n{\"id\":2,\"title\":\"Second\"}\n", out.String())
|
||||
}
|
||||
|
||||
func TestNotificationMutationsReportWhatChanged(t *testing.T) {
|
||||
var marks []string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/notifications/threads/12":
|
||||
marks = append(marks, r.URL.Query().Get("to-status"))
|
||||
_, _ = w.Write([]byte(`{"id":12,"repository":{"full_name":"alice/project"},"subject":{"type":"Pull","url":"https://gitea.test/api/v1/repos/alice/project/issues/17"}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/notifications/threads/12":
|
||||
_, _ = w.Write([]byte(`{"id":12,"unread":true,"repository":{"full_name":"alice/project"},"subject":{"title":"Improve greeting","type":"Pull","state":"open","html_url":"https://gitea.test/alice/project/pulls/17","url":"https://gitea.test/api/v1/repos/alice/project/issues/17","latest_comment_url":"https://gitea.test/api/v1/repos/alice/project/issues/comments/9"}}`))
|
||||
case r.URL.Path == "/api/v1/repos/alice/project/issues/comments/9":
|
||||
_, _ = w.Write([]byte(`{"id":9,"body":"Looks good to me","user":{"login":"bob"}}`))
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/notifications":
|
||||
marks = append(marks, "all "+r.URL.Query().Get("to-status"))
|
||||
_, _ = w.Write([]byte(`[{"id":12},{"id":3}]`))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics := serverDeps(t, server.URL)
|
||||
deps.Streams.OutTTY = true
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"pin", "12"}, "✓ Pinned notification 12 (alice/project#17)\n"},
|
||||
{[]string{"unpin", "#12"}, "✓ Unpinned notification 12 (alice/project#17)\n"},
|
||||
{[]string{"mark-read", "12"}, "✓ Marked notification 12 (alice/project#17) as read\n"},
|
||||
{[]string{"mark-read", "--all", "--yes"}, "✓ Marked 2 notifications as read\n"},
|
||||
} {
|
||||
diagnostics.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, append([]string{"notification"}, tc.args...)), tc.args)
|
||||
require.Equal(t, tc.want, withoutANSI(diagnostics.String()), tc.args)
|
||||
}
|
||||
require.Equal(t, []string{"pinned", "read", "read", "all read"}, marks)
|
||||
marks = nil
|
||||
out.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"notification", "view", "12"}))
|
||||
require.Contains(t, withoutANSI(out.String()), "Latest comment: bob\n\n Looks good to me\n\nView this pull request on Gitea: https://gitea.test/alice/project/pulls/17\n")
|
||||
require.Equal(t, []string{"read"}, marks, "viewing marks an unread notification as read")
|
||||
}
|
||||
@@ -85,10 +85,9 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
||||
{[]string{"milestone", "issue", "remove", "7", "1", "1", "--no-input"}, "resource 1 is selected more than once"},
|
||||
{[]string{"milestone", "list", "--limit", "1001"}, `"-L, --limit" flag: must be 1..1000`},
|
||||
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
|
||||
{[]string{"notification", "list", "--status", "invalid"}, "--status must contain unread, read, or pinned"},
|
||||
{[]string{"notification", "list", "--type", "invalid"}, "--type must contain issue, pull, commit, or repository"},
|
||||
{[]string{"notification", "list", "--status", "invalid"}, "--status must be unread, read, or pinned"},
|
||||
{[]string{"notification", "list", "--type", "invalid"}, "--type must be issue, pull, commit, or repository"},
|
||||
{[]string{"notification", "list", "--limit", "many"}, `"-L, --limit" flag: must be 1..1000`},
|
||||
{[]string{"notification", "list", "--mine", "-R", "alice/project"}, "--mine and --repo are mutually exclusive"},
|
||||
{[]string{"notification", "list", "--since="}, "invalid --since: use a date, RFC3339 timestamp, or duration such as 24h"},
|
||||
{[]string{"notification", "list", "--since=1h", "--before=2h"}, "--since must not be later than --before"},
|
||||
{[]string{"notification", "mark-read"}, "supply notification IDs or --all"},
|
||||
@@ -96,8 +95,8 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
||||
{[]string{"notification", "mark-read", "1", "--status", "unread"}, "--status and --before require --all"},
|
||||
{[]string{"notification", "mark-read", "--all", "--before="}, "invalid --before: use a date, RFC3339 timestamp, or duration such as 24h"},
|
||||
{[]string{"notification", "mark-read", "9", "09"}, "resource 9 is selected more than once"},
|
||||
{[]string{"notification", "watch", "--interval", "0s"}, "--interval and --timeout must be positive"},
|
||||
{[]string{"notification", "watch", "--timeout", "0s"}, "--interval and --timeout must be positive"},
|
||||
{[]string{"notification", "watch", "--interval", "0s"}, "--interval must be positive and --timeout not negative"},
|
||||
{[]string{"notification", "watch", "--timeout", "-1s"}, "--interval must be positive and --timeout not negative"},
|
||||
{[]string{"notification", "watch", "--before=-1h"}, "invalid --before: relative duration cannot be negative"},
|
||||
{[]string{"org", "create", "team", "--visibility", "unknown"}, "--visibility must be public, limited, or private"},
|
||||
{[]string{"org", "edit", "team"}, "supply organization fields to edit"},
|
||||
|
||||
Reference in new issue
Block a user