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:
bircni committed 2026-10-11 23:50:46 +02:00
1 parent 014dba8512
commit 85034c524f
6 files changed
+569 -200

No files matched your search

+383 -153
View File
@@ -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
})
}