Files
gitea-tea/internal/app/notifications.go
T
bircni 85034c524f 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>
2026-10-11 23:50:46 +02:00

422 lines
16 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"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: "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 notificationScope{}, err
}
where := "in your inbox"
if t.Repo != "" {
where = "in " + t.Repo
}
return notificationScope{services.Notifications{Client: t.client, Repository: t.Repo}, where}, nil
}
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"},
} {
var all bool
var filters []string
var before string
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"))
}
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
}
// 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")
}
// 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
})
}